diff --git a/package-lock.json b/package-lock.json index fc773ccae17..3b6c2fc97c8 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2050,18 +2050,6 @@ "node": ">=6.9.0" } }, - "node_modules/@babel/helper-module-imports/node_modules/jsesc": { - "version": "3.1.0", - "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", - "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", - "license": "MIT", - "bin": { - "jsesc": "bin/jsesc" - }, - "engines": { - "node": ">=6" - } - }, "node_modules/@babel/helper-module-transforms": { "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.27.1.tgz", @@ -3778,7 +3766,6 @@ "version": "7.27.1", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.27.1.tgz", "integrity": "sha512-fBJKiV7F2DxZUkg5EtHKXQdbsbURW3DZKQUWphDum0uRP6eHGGa/He9mc0mypL680pb+e/lDIthRohlv8NCHkg==", - "dev": true, "license": "MIT", "dependencies": { "@babel/helper-plugin-utils": "^7.27.1" @@ -32175,6 +32162,8 @@ }, "node_modules/@tanstack/react-query": { "version": "5.62.7", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.62.7.tgz", + "integrity": "sha512-+xCtP4UAFDTlRTYyEjLx0sRtWyr5GIk7TZjZwBu4YaNahi3Rt2oMyRqfpfVrtwsqY2sayP4iXVCwmC+ZqqFmuw==", "license": "MIT", "dependencies": { "@tanstack/query-core": "5.62.7" @@ -32189,6 +32178,8 @@ }, "node_modules/@tanstack/react-query-devtools": { "version": "5.62.7", + "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.62.7.tgz", + "integrity": "sha512-wxXsdTZJRs//hMtJMU5aNlUaTclRFPqLvDNeWbRj8YpGD3aoo4zyu53W55W2DY16+ycg3fti21uCW4N9oyj91w==", "license": "MIT", "dependencies": { "@tanstack/query-devtools": "5.61.4" @@ -97983,8 +97974,7 @@ "@jup-ag/api": "6.0.44", "@metaplex-foundation/mpl-token-metadata": "2.5.2", "@optimizely/optimizely-sdk": "4.0.0", - "@tanstack/react-query": "5.90.16", - "@tanstack/react-query-devtools": "5.91.2", + "@tanstack/react-query": "5.62.7", "@yornaath/batshit": "0.10.1", "async-retry": "1.3.3", "dayjs": "^1.11.19", @@ -98051,59 +98041,6 @@ "version": "5.2.1", "license": "MIT" }, - "packages/common/node_modules/@tanstack/query-core": { - "version": "5.90.16", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.16.tgz", - "integrity": "sha512-MvtWckSVufs/ja463/K4PyJeqT+HMlJWtw6PrCpywznd2NSgO3m4KwO9RqbFqGg6iDE8vVMFWMeQI4Io3eEYww==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - } - }, - "packages/common/node_modules/@tanstack/query-devtools": { - "version": "5.92.0", - "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.92.0.tgz", - "integrity": "sha512-N8D27KH1vEpVacvZgJL27xC6yPFUy0Zkezn5gnB3L3gRCxlDeSuiya7fKge8Y91uMTnC8aSxBQhcK6ocY7alpQ==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - } - }, - "packages/common/node_modules/@tanstack/react-query": { - "version": "5.90.16", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.16.tgz", - "integrity": "sha512-bpMGOmV4OPmif7TNMteU/Ehf/hoC0Kf98PDc0F4BZkFrEapRMEqI/V6YS0lyzwSV6PQpY1y4xxArUIfBW5LVxQ==", - "license": "MIT", - "dependencies": { - "@tanstack/query-core": "5.90.16" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - }, - "peerDependencies": { - "react": "^18 || ^19" - } - }, - "packages/common/node_modules/@tanstack/react-query-devtools": { - "version": "5.91.2", - "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.91.2.tgz", - "integrity": "sha512-ZJ1503ay5fFeEYFUdo7LMNFzZryi6B0Cacrgr2h1JRkvikK1khgIq6Nq2EcblqEdIlgB/r7XDW8f8DQ89RuUgg==", - "license": "MIT", - "dependencies": { - "@tanstack/query-devtools": "5.92.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - }, - "peerDependencies": { - "@tanstack/react-query": "^5.90.14", - "react": "^18 || ^19" - } - }, "packages/common/node_modules/@types/numeral": { "version": "2.0.2", "dev": true, @@ -124851,62 +124788,6 @@ "@babel/core": "^7.0.0-0" } }, - "packages/mobile/node_modules/@babel/plugin-transform-runtime": { - "version": "7.26.10", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-runtime/-/plugin-transform-runtime-7.26.10.tgz", - "integrity": "sha512-NWaL2qG6HRpONTnj4JvDU6th4jYeZOJgu3QhmFTCihib0ermtOJqktA5BduGm3suhhVe9EMP9c9+mfJ/I9slqw==", - "dev": true, - "dependencies": { - "@babel/helper-module-imports": "^7.25.9", - "@babel/helper-plugin-utils": "^7.26.5", - "babel-plugin-polyfill-corejs2": "^0.4.10", - "babel-plugin-polyfill-corejs3": "^0.11.0", - "babel-plugin-polyfill-regenerator": "^0.6.1", - "semver": "^6.3.1" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, - "packages/mobile/node_modules/@babel/plugin-transform-runtime/node_modules/babel-plugin-polyfill-corejs3": { - "version": "0.11.1", - "resolved": "https://registry.npmjs.org/babel-plugin-polyfill-corejs3/-/babel-plugin-polyfill-corejs3-0.11.1.tgz", - "integrity": "sha512-yGCqvBT4rwMczo28xkH/noxJ6MZ4nJfkVYdoDaC/utLtWrXxv27HVrzAeSbqR8SxDsp46n0YF47EbHoixy6rXQ==", - "dev": true, - "dependencies": { - "@babel/helper-define-polyfill-provider": "^0.6.3", - "core-js-compat": "^3.40.0" - }, - "peerDependencies": { - "@babel/core": "^7.4.0 || ^8.0.0-0 <8.0.0" - } - }, - "packages/mobile/node_modules/@babel/plugin-transform-runtime/node_modules/semver": { - "version": "6.3.1", - "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", - "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", - "dev": true, - "bin": { - "semver": "bin/semver.js" - } - }, - "packages/mobile/node_modules/@babel/plugin-transform-template-literals": { - "version": "7.26.8", - "resolved": "https://registry.npmjs.org/@babel/plugin-transform-template-literals/-/plugin-transform-template-literals-7.26.8.tgz", - "integrity": "sha512-OmGDL5/J0CJPJZTHZbi2XpO0tyT2Ia7fzpW5GURwdtp2X3fMmN8au/ej6peC/T33/+CRiIpA8Krse8hFGVmT5Q==", - "dependencies": { - "@babel/helper-plugin-utils": "^7.26.5" - }, - "engines": { - "node": ">=6.9.0" - }, - "peerDependencies": { - "@babel/core": "^7.0.0-0" - } - }, "packages/mobile/node_modules/@babel/plugin-transform-typeof-symbol": { "version": "7.27.0", "resolved": "https://registry.npmjs.org/@babel/plugin-transform-typeof-symbol/-/plugin-transform-typeof-symbol-7.27.0.tgz", @@ -132813,8 +132694,8 @@ "@solana/web3.js": "1.98.0", "@stripe/crypto": "0.0.4", "@stripe/stripe-js": "1.54.1", - "@tanstack/react-query": "5.90.16", - "@tanstack/react-query-devtools": "5.91.2", + "@tanstack/react-query": "5.62.7", + "@tanstack/react-query-devtools": "5.62.7", "@wagmi/connectors": "5.7.11", "array-pack-2d": "0.1.1", "array-range": "1.0.1", @@ -134298,59 +134179,6 @@ "@swc/counter": "^0.1.3" } }, - "packages/web/node_modules/@tanstack/query-core": { - "version": "5.90.16", - "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.90.16.tgz", - "integrity": "sha512-MvtWckSVufs/ja463/K4PyJeqT+HMlJWtw6PrCpywznd2NSgO3m4KwO9RqbFqGg6iDE8vVMFWMeQI4Io3eEYww==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - } - }, - "packages/web/node_modules/@tanstack/query-devtools": { - "version": "5.92.0", - "resolved": "https://registry.npmjs.org/@tanstack/query-devtools/-/query-devtools-5.92.0.tgz", - "integrity": "sha512-N8D27KH1vEpVacvZgJL27xC6yPFUy0Zkezn5gnB3L3gRCxlDeSuiya7fKge8Y91uMTnC8aSxBQhcK6ocY7alpQ==", - "license": "MIT", - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - } - }, - "packages/web/node_modules/@tanstack/react-query": { - "version": "5.90.16", - "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.90.16.tgz", - "integrity": "sha512-bpMGOmV4OPmif7TNMteU/Ehf/hoC0Kf98PDc0F4BZkFrEapRMEqI/V6YS0lyzwSV6PQpY1y4xxArUIfBW5LVxQ==", - "license": "MIT", - "dependencies": { - "@tanstack/query-core": "5.90.16" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - }, - "peerDependencies": { - "react": "^18 || ^19" - } - }, - "packages/web/node_modules/@tanstack/react-query-devtools": { - "version": "5.91.2", - "resolved": "https://registry.npmjs.org/@tanstack/react-query-devtools/-/react-query-devtools-5.91.2.tgz", - "integrity": "sha512-ZJ1503ay5fFeEYFUdo7LMNFzZryi6B0Cacrgr2h1JRkvikK1khgIq6Nq2EcblqEdIlgB/r7XDW8f8DQ89RuUgg==", - "license": "MIT", - "dependencies": { - "@tanstack/query-devtools": "5.92.0" - }, - "funding": { - "type": "github", - "url": "https://github.com/sponsors/tannerlinsley" - }, - "peerDependencies": { - "@tanstack/react-query": "^5.90.14", - "react": "^18 || ^19" - } - }, "packages/web/node_modules/@testing-library/jest-dom": { "version": "6.6.3", "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.6.3.tgz", diff --git a/packages/common/package.json b/packages/common/package.json index 6ca73bbf050..25347972946 100644 --- a/packages/common/package.json +++ b/packages/common/package.json @@ -42,8 +42,7 @@ "@jup-ag/api": "6.0.44", "@metaplex-foundation/mpl-token-metadata": "2.5.2", "@optimizely/optimizely-sdk": "4.0.0", - "@tanstack/react-query": "5.90.16", - "@tanstack/react-query-devtools": "5.91.2", + "@tanstack/react-query": "5.62.7", "@yornaath/batshit": "0.10.1", "async-retry": "1.3.3", "dayjs": "^1.11.19", diff --git a/packages/common/src/api/tan-query/upload/mutationOptions.ts b/packages/common/src/api/tan-query/upload/mutationOptions.ts new file mode 100644 index 00000000000..24536999f7d --- /dev/null +++ b/packages/common/src/api/tan-query/upload/mutationOptions.ts @@ -0,0 +1,45 @@ +// TEMPORARY PORT OF TANSTACK QUERY TYPES +// To avoid dependency issues with newer versions of tanstack query +// Remove when we can upgrade Tanstack Query without breaking the production static builds + +import type { DefaultError, WithRequired } from '@tanstack/query-core' +import type { UseMutationOptions } from '@tanstack/react-query' + +export function mutationOptions< + TData = unknown, + TError = DefaultError, + TVariables = void, + TOnMutateResult = unknown +>( + options: WithRequired< + UseMutationOptions, + 'mutationKey' + > +): WithRequired< + UseMutationOptions, + 'mutationKey' +> +export function mutationOptions< + TData = unknown, + TError = DefaultError, + TVariables = void, + TOnMutateResult = unknown +>( + options: Omit< + UseMutationOptions, + 'mutationKey' + > +): Omit< + UseMutationOptions, + 'mutationKey' +> +export function mutationOptions< + TData = unknown, + TError = DefaultError, + TVariables = void, + TOnMutateResult = unknown +>( + options: UseMutationOptions +): UseMutationOptions { + return options +} diff --git a/packages/common/src/api/tan-query/upload/usePublishCollection.ts b/packages/common/src/api/tan-query/upload/usePublishCollection.ts index 7489d48d594..ded16f5b2c2 100644 --- a/packages/common/src/api/tan-query/upload/usePublishCollection.ts +++ b/packages/common/src/api/tan-query/upload/usePublishCollection.ts @@ -1,9 +1,5 @@ import { HashId, Id, type UploadResponse } from '@audius/sdk' -import { - mutationOptions, - useMutation, - useQueryClient -} from '@tanstack/react-query' +import { useMutation, useQueryClient } from '@tanstack/react-query' import { mapValues } from 'lodash' import { useDispatch } from 'react-redux' @@ -33,6 +29,7 @@ import { addPremiumMetadata, getUSDCMetadata } from './usePublishTracks' +import { mutationOptions } from './mutationOptions' type PublishCollectionContext = Pick< QueryContextType, diff --git a/packages/common/src/api/tan-query/upload/usePublishStems.ts b/packages/common/src/api/tan-query/upload/usePublishStems.ts index b8c7e801515..111daae329b 100644 --- a/packages/common/src/api/tan-query/upload/usePublishStems.ts +++ b/packages/common/src/api/tan-query/upload/usePublishStems.ts @@ -1,9 +1,5 @@ import { HashId, Id, type UploadResponse } from '@audius/sdk' -import { - mutationOptions, - useMutation, - useQueryClient -} from '@tanstack/react-query' +import { useMutation, useQueryClient } from '@tanstack/react-query' import { StemCategory, @@ -16,6 +12,7 @@ import { ProgressStatus, uploadActions } from '~/store' import { getStemsQueryKey } from '../tracks/useStems' import { useCurrentUserId } from '../users/account/useCurrentUserId' import { useQueryContext, type QueryContextType } from '../utils' +import { mutationOptions } from './mutationOptions' const { updateProgress } = uploadActions diff --git a/packages/common/src/api/tan-query/upload/usePublishTracks.ts b/packages/common/src/api/tan-query/upload/usePublishTracks.ts index 7a4624d5cdb..0e0129bffa1 100644 --- a/packages/common/src/api/tan-query/upload/usePublishTracks.ts +++ b/packages/common/src/api/tan-query/upload/usePublishTracks.ts @@ -1,10 +1,6 @@ import { USDC } from '@audius/fixed-decimal' import { HashId, Id, type UploadResponse } from '@audius/sdk' -import { - mutationOptions, - useMutation, - useQueryClient -} from '@tanstack/react-query' +import { useMutation, useQueryClient } from '@tanstack/react-query' import { trackMetadataForUploadToSdk } from '~/adapters' import { @@ -23,6 +19,7 @@ import { useCurrentAccount } from '../users/account/useCurrentAccount' import { getUserQueryKey } from '../users/useUser' import { useQueryContext, type QueryContextType } from '../utils' import { publishStems } from './usePublishStems' +import { mutationOptions } from './mutationOptions' const { updateProgress } = uploadActions diff --git a/packages/common/src/api/tan-query/upload/useUpload.ts b/packages/common/src/api/tan-query/upload/useUpload.ts index 281b588ce80..ffe8c66c6d3 100644 --- a/packages/common/src/api/tan-query/upload/useUpload.ts +++ b/packages/common/src/api/tan-query/upload/useUpload.ts @@ -518,7 +518,7 @@ export const useUpload = () => { }, feature: Feature.Upload }) - dispatch(uploadActions.uploadTracksFailed()) + dispatch(uploadTracksFailed()) } } }, diff --git a/packages/common/src/api/tan-query/upload/useUploadFiles.ts b/packages/common/src/api/tan-query/upload/useUploadFiles.ts index 5bd87d52326..533379d9e5b 100644 --- a/packages/common/src/api/tan-query/upload/useUploadFiles.ts +++ b/packages/common/src/api/tan-query/upload/useUploadFiles.ts @@ -1,5 +1,6 @@ import { type AudiusSdk } from '@audius/sdk' -import { mutationOptions, useMutation } from '@tanstack/react-query' +import { useMutation } from '@tanstack/react-query' +import { mutationOptions } from './mutationOptions' type UploadFile = { clientId: string diff --git a/packages/common/src/store/upload/reducer.ts b/packages/common/src/store/upload/reducer.ts index 95cf77d21ae..aa9dba7cc34 100644 --- a/packages/common/src/store/upload/reducer.ts +++ b/packages/common/src/store/upload/reducer.ts @@ -180,17 +180,20 @@ const actionsMap = { if ( stemIndex !== null && - newState.uploadProgress[trackIndex] && - !newState.uploadProgress[trackIndex]?.stems[stemIndex] + !newState.uploadProgress[trackIndex].stems[stemIndex] ) { - newState.uploadProgress[trackIndex].stems[stemIndex] = { - ...cloneDeep(initialUploadState), - image: { - status: ProgressStatus.COMPLETE, - loaded: 0, - total: 0, - transcode: 0 - } + const stems = newState.uploadProgress[trackIndex].stems + while (stems.length <= stemIndex) { + stems.push({ + ...cloneDeep(initialUploadState), + image: { + status: ProgressStatus.COMPLETE, + loaded: 0, + total: 0, + transcode: 0 + }, + clientId + }) } } diff --git a/packages/common/src/store/upload/selectors.ts b/packages/common/src/store/upload/selectors.ts index f7c04dcce06..aa126520faa 100644 --- a/packages/common/src/store/upload/selectors.ts +++ b/packages/common/src/store/upload/selectors.ts @@ -34,23 +34,10 @@ const trackProgressSummary = ( trackProgress[key].status === ProgressStatus.ERROR ? 1 : (trackProgress[key].transcode ?? 0) - const transcodeTotal = 1 + trackProgress.stems.length - for (const stemProgress of trackProgress.stems) { - loaded += - stemProgress[key].status === ProgressStatus.ERROR - ? (stemProgress[key].total ?? 0) - : (stemProgress[key].loaded ?? 0) - total += stemProgress[key].total ?? 0 - transcode += - stemProgress[key].status === ProgressStatus.ERROR - ? 1 - : (stemProgress[key].transcode ?? 0) - } return { - loaded, - total, - transcode: key === 'audio' ? transcode / transcodeTotal : 0 + upload: total === 0 ? 0 : loaded / total, + transcode: key === 'audio' ? transcode : 1 } } @@ -65,33 +52,45 @@ const getKeyUploadProgress = (state: CommonState, key: 'image' | 'audio') => { const filteredProgress = uploadProgress.filter((progress) => key in progress) if (filteredProgress.length === 0) return 0 - let loaded = 0 - let total = 0 + let uploaded = 0 let transcoded = 0 for (const trackProgress of filteredProgress) { const summary = trackProgressSummary(trackProgress, key) - loaded += summary.loaded - transcoded += summary.transcode * summary.total - total += summary.total + uploaded += summary.upload + transcoded += summary.transcode + for (const stemProgress of trackProgress.stems) { + const stemSummary = trackProgressSummary(stemProgress, key) + uploaded += stemSummary.upload + transcoded += stemSummary.transcode + } } - const fileUploadProgress = total === 0 ? 0 : loaded / total - const transcodeProgress = total === 0 ? 0 : transcoded / total - const overallProgress = key === 'image' - ? fileUploadProgress - : UPLOAD_WEIGHT * fileUploadProgress + - TRANSCODE_WEIGHT * transcodeProgress + ? uploaded + : UPLOAD_WEIGHT * uploaded + TRANSCODE_WEIGHT * transcoded return overallProgress } export const getCombinedUploadPercentage = (state: CommonState) => { + if ( + state.upload.formState == null || + state.upload.formState.tracks === undefined + ) + return 0 + const trackCount = state.upload.formState.tracks.length + const stemCount = state.upload.formState.tracks.reduce((acc, track) => { + return acc + (track.metadata.stems ? track.metadata.stems.length : 0) + }, 0) + const totalItems = trackCount + stemCount + if (totalItems === 0) return 0 + const imageProgress = getKeyUploadProgress(state, 'image') const audioProgress = getKeyUploadProgress(state, 'audio') const percent = floor( - 100 * (IMAGE_WEIGHT * imageProgress + AUDIO_WEIGHT * audioProgress) + (100 * (IMAGE_WEIGHT * imageProgress + AUDIO_WEIGHT * audioProgress)) / + totalItems ) return clamp(percent, 0, 100) } diff --git a/packages/web/package.json b/packages/web/package.json index a3bec5e4fa4..9edab521d1a 100644 --- a/packages/web/package.json +++ b/packages/web/package.json @@ -98,8 +98,8 @@ "@solana/web3.js": "1.98.0", "@stripe/crypto": "0.0.4", "@stripe/stripe-js": "1.54.1", - "@tanstack/react-query": "5.90.16", - "@tanstack/react-query-devtools": "5.91.2", + "@tanstack/react-query": "5.62.7", + "@tanstack/react-query-devtools": "5.62.7", "@wagmi/connectors": "5.7.11", "array-pack-2d": "0.1.1", "array-range": "1.0.1", diff --git a/packages/web/src/common/store/pages/profile/lineups/tracks/sagas.js b/packages/web/src/common/store/pages/profile/lineups/tracks/sagas.js index 2f5373dc1f8..fab3543e59c 100644 --- a/packages/web/src/common/store/pages/profile/lineups/tracks/sagas.js +++ b/packages/web/src/common/store/pages/profile/lineups/tracks/sagas.js @@ -18,7 +18,6 @@ import { LineupSagas } from 'common/store/lineup/sagas' import { waitForRead } from 'utils/sagaHelpers' import { retrieveUserTracks } from './retrieveUserTracks' -import { watchUploadTracksSaga } from './watchUploadTracksSaga' const { SET_ARTIST_PICK } = tracksSocialActions const { getProfileTracksLineup, getTrackSource } = profilePageSelectors @@ -106,9 +105,5 @@ function* watchDeleteTrackRequested() { export default function sagas() { const trackSagas = new TracksSagas().getSagas() - return trackSagas.concat([ - watchSetArtistPick, - watchDeleteTrackRequested, - watchUploadTracksSaga - ]) + return trackSagas.concat([watchSetArtistPick, watchDeleteTrackRequested]) } diff --git a/packages/web/src/common/store/pages/profile/lineups/tracks/watchUploadTracksSaga.ts b/packages/web/src/common/store/pages/profile/lineups/tracks/watchUploadTracksSaga.ts deleted file mode 100644 index 31036ed10fd..00000000000 --- a/packages/web/src/common/store/pages/profile/lineups/tracks/watchUploadTracksSaga.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { queryAccountUser } from '@audius/common/api' -import { Collection, Kind, Track } from '@audius/common/models' -import { - profilePageTracksLineupActions, - profilePageSelectors, - uploadActions, - UploadType -} from '@audius/common/store' -import { makeUid } from '@audius/common/utils' -import { call, put, select, takeEvery } from 'typed-redux-saga' - -const { UPLOAD_TRACKS_SUCCEEDED, uploadTracksSucceeded } = uploadActions -const { getTrackSource } = profilePageSelectors - -type UploadTracksSucceededAction = ReturnType - -export function* watchUploadTracksSaga() { - yield* takeEvery(UPLOAD_TRACKS_SUCCEEDED, addUploadedTrackToLineup) -} - -const isTrackEntity = (entity: Track | Collection): entity is Track => - (entity as Track).track_id !== undefined - -function* addUploadedTrackToLineup(action: UploadTracksSucceededAction) { - const accountUser = yield* call(queryAccountUser) - const accountHandle = accountUser?.handle - const { uploadType, completedEntity } = action - - // We will only be adding single track uploads since multi-adds for lineups - // are cumbersome. When we want to suport multi-track upload on mobile or - // properly cache desktop profile lineups, we should revisit this. - if ( - !completedEntity || - !isTrackEntity(completedEntity) || - !accountHandle || - uploadType !== UploadType.INDIVIDUAL_TRACK - ) - return - - const uploadedTrack = completedEntity - const id = uploadedTrack.track_id - const source = yield* select(getTrackSource, accountHandle) - - const uploadedTrackLineupEntry = { - kind: Kind.TRACKS, - id, - uid: makeUid(Kind.TRACKS, id), - source, - ...uploadedTrack - } - - yield* put( - profilePageTracksLineupActions.add( - uploadedTrackLineupEntry, - id, - accountHandle, - true - ) - ) -} diff --git a/packages/web/src/common/store/upload/sagas.d.ts b/packages/web/src/common/store/upload/sagas.d.ts deleted file mode 100644 index c5a1b07ab89..00000000000 --- a/packages/web/src/common/store/upload/sagas.d.ts +++ /dev/null @@ -1,13 +0,0 @@ -// eslint-disable-next-line -export declare function* handleUploads(config: { - tracks: any[] - isCollection: boolean - isStem?: boolean - isAlbum?: boolean -}) - -export default function sagas(): (() => Generator< - ForkEffect, - void, - unknown ->)[] diff --git a/packages/web/src/common/store/upload/sagas.ts b/packages/web/src/common/store/upload/sagas.ts deleted file mode 100644 index 2ccd7bd2aba..00000000000 --- a/packages/web/src/common/store/upload/sagas.ts +++ /dev/null @@ -1,1348 +0,0 @@ -import { - albumMetadataForCreateWithSDK, - fileToSdk, - playlistMetadataForCreateWithSDK, - stemTrackMetadataFromSDK, - trackMetadataForUploadToSdk, - transformAndCleanList, - userCollectionMetadataFromSDK -} from '@audius/common/adapters' -import { - getStemsQueryKey, - queryAccountUser, - queryCurrentUserId, - queryTracks, - queryUser, - primeCollectionDataSaga, - getUserQueryKey, - QUERY_KEYS -} from '@audius/common/api' -import { - Collection, - Feature, - FieldVisibility, - ID, - Name, - StemTrack, - StemUploadWithFile, - isContentFollowGated, - isContentUSDCPurchaseGated -} from '@audius/common/models' -import { CollectionValues } from '@audius/common/schemas' -import { - TrackMetadataForUpload, - LibraryCategory, - ProgressStatus, - TrackForUpload, - UploadType, - accountActions, - confirmerActions, - getContext, - libraryPageActions, - uploadActions, - getSDK, - cacheTracksActions, - replaceTrackProgressModalActions, - queueSelectors, - queueActions, - playerActions -} from '@audius/common/store' -import { actionChannelDispatcher, waitForAccount } from '@audius/common/utils' -import { - Id, - OptionalId, - ProgressHandler, - AudiusSdk, - OptionalHashId -} from '@audius/sdk' -import { mapValues } from 'lodash' -import { Channel, Task, buffers, channel } from 'redux-saga' -import { - all, - call, - cancel, - fork, - put, - takeLatest, - take, - delay, - select, - retry -} from 'typed-redux-saga' - -import { make } from 'common/store/analytics/actions' -import { prepareStemsForUpload } from 'pages/upload-page/store/utils/stems' -import * as errorActions from 'store/errors/actions' -import { reportToSentry } from 'store/errors/reportToSentry' -import { push } from 'utils/navigation' -import { waitForWrite } from 'utils/sagaHelpers' - -import { trackNewRemixEvent } from '../cache/tracks/sagas' - -import { - addPremiumMetadata, - getUSDCMetadata, - recordGatedTracks -} from './sagaHelpers' - -const { updateProgress } = uploadActions - -type ProgressAction = ReturnType - -const MAX_CONCURRENT_UPLOADS = 6 -const MAX_CONCURRENT_PUBLISHES = 6 - -/** - * Combines the metadata for a track and a collection (playlist or album), - * taking the metadata from the playlist when the track is missing it. - */ -function* combineMetadata( - trackMetadata: TrackMetadataForUpload, - collectionMetadata: CollectionValues, - albumTrackPrice?: number -) { - const metadata = trackMetadata - - // @ts-expect-error - Typing is hard here because playlists and albums have different artwork types - metadata.artwork = collectionMetadata.artwork - - if (!metadata.genre) - metadata.genre = collectionMetadata.trackDetails?.genre ?? '' - if (!metadata.mood) - metadata.mood = collectionMetadata.trackDetails?.mood ?? null - if (!metadata.release_date) { - metadata.release_date = collectionMetadata.release_date ?? null - metadata.is_scheduled_release = - collectionMetadata.is_scheduled_release ?? false - } - - if (metadata.tags === null && collectionMetadata.trackDetails?.tags) { - // Take collection tags - metadata.tags = collectionMetadata.trackDetails?.tags - } - - // Set download & hidden status - metadata.is_downloadable = !!collectionMetadata.is_downloadable - - metadata.is_unlisted = !!collectionMetadata.is_private - if (collectionMetadata.is_private && collectionMetadata.field_visibility) { - // Convert any undefined values to booleans - const booleanFieldVisibility = mapValues( - collectionMetadata.field_visibility, - Boolean - ) as FieldVisibility - metadata.field_visibility = booleanFieldVisibility - } - - // If the tracks were added as part of a premium album, add all the necessary premium track metadata - if (albumTrackPrice !== undefined && albumTrackPrice > 0) { - // is_download_gated must always be set to true for all premium tracks - metadata.is_download_gated = true - metadata.download_conditions = { - usdc_purchase: { - price: albumTrackPrice, - splits: { 0: 0 } - } - } - // Set up initial stream gating values - metadata.is_stream_gated = true - metadata.preview_start_seconds = 0 - metadata.stream_conditions = { - usdc_purchase: { price: albumTrackPrice, splits: { 0: 0 } } - } - // Add splits to stream & download conditions - yield* call(addPremiumMetadata, metadata) - } - return metadata -} - -/** - * Creates a callback that runs on upload progress in sdk and - * reports that progress to the store via actions. - */ -const makeOnProgress = ( - trackIndex: number, - stemIndex: number | null, - progressChannel: Channel -) => { - return (progress: Parameters[0]) => { - const key = 'audio' in progress ? 'audio' : 'art' - const p = - 'audio' in progress - ? progress.audio - : 'art' in progress - ? progress.art - : null - if (p === null) { - return - } - const { upload, transcode } = p - try { - progressChannel.put( - updateProgress({ - trackIndex, - stemIndex, - key, - progress: { - loaded: upload?.loaded, - total: upload?.total, - transcode: transcode?.decimal, - status: - !upload || upload.loaded !== upload.total - ? ProgressStatus.UPLOADING - : ProgressStatus.PROCESSING - } - }) - ) - } catch { - // Sometimes this can fail repeatedly in quick succession (root cause TBD) - // it doesn't seem to affect the CX so catch to avoid spamming sentry - } - } -} - -/** - * Deletes a list of tracks by track IDs. - * Used in cleaning up orphaned stems or tracks of a collection. - */ -export function* deleteTracks(trackIds: ID[]) { - const sdk = yield* getSDK() - const userId = Id.parse(yield* call(queryCurrentUserId)) - if (!userId) { - throw new Error('No user id found during delete. Not signed in?') - } - - yield* all( - trackIds.map((id) => - call([sdk.tracks, sdk.tracks.deleteTrack], { - userId, - trackId: Id.parse(id) - }) - ) - ) -} - -/** - * Worker that handles upload requests. - * @param uploadQueue The queue of upload requests to pull from. - * @param progressChannel The channel to report progress actions to. - * @param responseChannel The channel to report the outcome to. - */ -function* uploadWorker( - uploadQueue: Channel, - progressChannel: Channel, - responseChannel: Channel -) { - while (true) { - const task = yield* take(uploadQueue) - const { trackIndex, stemIndex, track } = task - try { - const sdk = yield* getSDK() - const userId = yield* call(queryCurrentUserId) - if (!userId) { - throw new Error('No user id found during upload. Not signed in?') - } - - const coverArtFile = - track.metadata.artwork && 'file' in track.metadata.artwork - ? (track.metadata.artwork?.file ?? null) - : null - const metadata = trackMetadataForUploadToSdk(track.metadata) - - const updatedMetadata = yield* call( - [sdk.tracks, sdk.tracks.uploadTrackFiles], - { - userId: Id.parse(userId), - trackFile: fileToSdk(track.file, 'audio'), - // coverArtFile will be undefined for stem uploads - coverArtFile: coverArtFile - ? fileToSdk(coverArtFile!, 'cover_art') - : undefined, - metadata, - onProgress: makeOnProgress(trackIndex, stemIndex, progressChannel) - } - ) - - yield* put(responseChannel, { - type: 'UPLOADED', - payload: { - trackIndex, - stemIndex, - metadata: updatedMetadata - } - }) - } catch (e) { - yield* put(responseChannel, { - type: 'ERROR', - payload: { - trackId: track.metadata.track_id, - phase: 'upload', - trackIndex, - stemIndex, - error: e - } - }) - } - } -} - -/** - * Worker that handles the entity manager writes. - * @param publishQueue The queue of publish requests - * @param responseChannel The channel to report the outcome to. - */ -function* publishWorker( - publishQueue: Channel, - responseChannel: Channel -) { - while (true) { - const task = yield* take(publishQueue) - const { trackIndex, stemIndex, metadata } = task - try { - const sdk = yield* getSDK() - const userId = yield* call(queryCurrentUserId) - if (!userId) { - throw new Error('No user id found during upload. Not signed in?') - } - - const { trackId: updatedTrackId } = yield* call( - [sdk.tracks, sdk.tracks.writeTrackToChain], - Id.parse(userId), - metadata - ) - - const decodedTrackId = OptionalHashId.parse(updatedTrackId) - - if (decodedTrackId) { - yield* put(responseChannel, { - type: 'PUBLISHED', - payload: { - trackIndex, - stemIndex, - trackId: decodedTrackId, - metadata - } - }) - } - } catch (e) { - yield* put(responseChannel, { - type: 'ERROR', - payload: { - trackId: metadata.trackId!, - phase: 'publish', - trackIndex, - stemIndex, - error: e - } - }) - } - } -} - -type UploadMetadata = Awaited< - ReturnType -> - -/** Queued task for the upload worker. */ -type UploadTask = { - trackIndex: number - stemIndex: number | null - track: TrackForUpload -} - -/** Upload worker success response payload */ -type UploadedPayload = { - trackIndex: number - stemIndex: number | null - metadata: UploadMetadata -} - -/** Queued task for the publish worker. */ -type PublishTask = { - trackIndex: number - stemIndex: number | null - trackId: ID - metadata: UploadMetadata -} - -/** Publish worker success response payload */ -type PublishedPayload = { - trackIndex: number - stemIndex: number | null - trackId: ID - metadata: UploadMetadata -} - -/** Error response payload (from either worker). */ -type ErrorPayload = { - trackIndex: number - stemIndex: number | null - trackId?: ID - phase: 'upload' | 'publish' - error: unknown -} - -/** All possible response payloads from workers. */ -type UploadTrackResponse = - | { type: 'UPLOADED'; payload: UploadedPayload } - | { type: 'PUBLISHED'; payload: PublishedPayload } - | { type: 'ERROR'; payload: ErrorPayload } - -function isTask(worker: unknown): worker is Task { - return worker !== null && typeof worker === 'object' && 'isRunning' in worker -} - -/** - * Spins up workers to handle uploading of tracks and their stems in parallel. - * - * Waits to publish parent tracks until their stems successfully upload. - * If a stem fails, marks the parent as failed and unpublishes the other stems. - * - * Waits to publish collection tracks until all other tracks successfully upload. - * If a track from a collection fails, marks the collection as failed and - * unpublishes all other collection tracks. - * - * Also called from the edit track flow for uploading stems to existing tracks. - * Those tracks must be uploaded as "tracks" since their parent is already - * uploaded, rather than sending their parent with stems attached. - */ -export function* handleUploads({ - tracks, - kind -}: { - tracks: TrackForUpload[] - kind: 'album' | 'playlist' | 'tracks' | 'stems' -}) { - const isCollection = kind === 'album' || kind === 'playlist' - const queryClient = yield* getContext('queryClient') - const userId = (yield* call(queryCurrentUserId))! - - // Queue for the upload tasks (uploading files to storage) - const uploadQueue = yield* call( - channel, - buffers.expanding(tracks.length) - ) - - // Queue for the publish tasks (writing to entity manager) - const publishQueue = yield* call( - channel, - buffers.expanding(tracks.length) - ) - - // Channel to listen for responses - const responseChannel = yield* call( - channel, - buffers.expanding(200) - ) - - // Channel to relay progress actions - const progressChannel = yield* call(channel) - const actionDispatcherTask = yield* fork( - actionChannelDispatcher, - progressChannel - ) - - console.debug(`Queuing tracks (and stems if applicable) to upload...`) - let stems = 0 - const pendingStemCount: Record = {} - for (let i = 0; i < tracks.length; i++) { - const track = tracks[i] - yield* put(uploadQueue, { trackIndex: i, stemIndex: null, track }) - - // Report analytics for each track - yield* put( - make(Name.TRACK_UPLOAD_TRACK_UPLOADING, { - artworkSource: - track.metadata.artwork && 'source' in track.metadata.artwork - ? track.metadata.artwork?.source - : undefined, - trackId: track.metadata.track_id, - genre: track.metadata.genre, - moode: track.metadata.mood, - size: track.file.size, - fileType: track.file.type, - name: track.file.name, - downloadable: isContentFollowGated(track.metadata.download_conditions) - ? 'follow' - : track.metadata.is_downloadable - ? 'yes' - : 'no' - }) - ) - - if (track.metadata.stems?.length) { - // track_id should be created and assigned ahead of time for a track with stems - if (!track.metadata.track_id) { - throw new Error('Track ID is required on parent track for stems') - } - // Process the track's stems - const trackStems = prepareStemsForUpload( - (track.metadata.stems ?? []) as StemUploadWithFile[], - track.metadata.track_id - ) - const stemCount = track.metadata.stems?.length ?? 0 - pendingStemCount[track.metadata.track_id] = stemCount - stems += stemCount - for (let j = 0; j < trackStems.length; j++) { - const stem = trackStems[j] - yield* put(uploadQueue, { trackIndex: i, stemIndex: j, track: stem }) - } - } - } - - const uploadRequestCount = tracks.length + stems - const numUploadWorkers = Math.min(uploadRequestCount, MAX_CONCURRENT_UPLOADS) - const numPublishWorkers = Math.min( - uploadRequestCount, - MAX_CONCURRENT_PUBLISHES - ) - - console.debug( - `Spinning up ${numUploadWorkers} upload workers and ${numPublishWorkers} publish workers to upload ${tracks.length} tracks and ${stems} stems` - ) - const uploadWorkers: Task[] = [] - for (let i = 0; i < numUploadWorkers; i++) { - uploadWorkers.push( - yield* fork(uploadWorker, uploadQueue, progressChannel, responseChannel) - ) - } - const publishWorkers: Task[] = [] - for (let i = 0; i < numPublishWorkers; i++) { - publishWorkers.push( - yield* fork(publishWorker, publishQueue, responseChannel) - ) - } - - const pendingMetadata: UploadMetadata[] = [] - - const errored: ErrorPayload[] = [] - const uploaded: UploadedPayload[] = [] - const published: PublishedPayload[] = [] - - // Setup handlers for all the task results - - /** Handler for successful upload worker tasks */ - function* handleUploaded(payload: UploadedPayload) { - uploaded.push(payload) - const { trackIndex, stemIndex, metadata } = payload - console.debug( - `${stemIndex === null && kind !== 'stems' ? 'Track' : 'Stem'} ${ - metadata.title - } uploaded`, - { trackIndex, stemIndex } - ) - - if (payload.metadata.stemOf?.parentTrackId) { - const parentTrackId = payload.metadata.stemOf.parentTrackId - - queryClient.setQueryData( - getStemsQueryKey(parentTrackId), - (currentStems: StemTrack[] | undefined) => { - const newStem = stemTrackMetadataFromSDK({ - ...payload.metadata, - id: Id.parse(parentTrackId + (payload.trackIndex + 1 + Date.now())), - parentId: Id.parse(parentTrackId), - userId: Id.parse(userId), - blocknumber: 0, - cid: '', - category: payload.metadata.stemOf!.category!, - origFilename: payload.metadata.origFilename! - }) - return [...(currentStems ?? []), newStem!] - } - ) - } - - // We know trackId exists because we generate it in uploadMultipleTracks - const trackId = metadata.trackId! - const stemCount = pendingStemCount[trackId] - - if (isCollection || stemCount > 0) { - // If parent track of stems or part of collection, - // save the metadata and wait to publish when the last stem (for parent - // track) or last track (for a collection) is uploaded. - pendingMetadata[trackIndex] = metadata - } else { - yield* put(publishQueue, { trackIndex, stemIndex, trackId, metadata }) - } - - if (isCollection && uploaded.length === tracks.length) { - // Publish the collection once all tracks are uploaded - for (let i = 0; i < pendingMetadata.length; i++) { - yield* put(publishQueue, { - trackIndex: i, - stemIndex: null, - trackId: pendingMetadata[i].trackId!, - metadata: pendingMetadata[i] - }) - } - } - } - - /** Handler for successful publish worker tasks */ - function* handlePublished(payload: PublishedPayload) { - published.push(payload) - const { trackIndex, stemIndex, trackId, metadata } = payload - console.debug( - `${stemIndex === null && kind !== 'stems' ? 'Track' : 'Stem'} ${ - metadata.title - } published`, - { trackId } - ) - - // Mark progress as complete - yield* put( - updateProgress({ - trackIndex, - stemIndex, - key: 'art', - progress: { status: ProgressStatus.COMPLETE } - }) - ) - yield* put( - updateProgress({ - trackIndex, - stemIndex, - key: 'audio', - progress: { status: ProgressStatus.COMPLETE } - }) - ) - - const parentTrackId = tracks[trackIndex].metadata.track_id - - // Report metrics - if (stemIndex !== null) { - yield* put( - make(Name.STEM_COMPLETE_UPLOAD, { - id: trackId, - parent_track_id: parentTrackId, - category: tracks[trackIndex].metadata.stems?.[stemIndex].category - }) - ) - } else { - yield* put(make(Name.TRACK_UPLOAD_SUCCESS, { kind })) - } - - // Trigger upload for parent if last stem - if (stemIndex !== null) { - if (!parentTrackId) { - throw new Error('Parent track ID not found for stem') - } - pendingStemCount[parentTrackId] -= 1 - if ( - pendingStemCount[parentTrackId] === 0 && - pendingMetadata[trackIndex] - ) { - console.debug( - `Stems finished for ${parentTrackId}, publishing parent: ${pendingMetadata[trackIndex].title}` - ) - yield* put(publishQueue, { - trackIndex, - stemIndex: null, - trackId: parentTrackId, - metadata: pendingMetadata[trackIndex] - }) - delete pendingMetadata[trackIndex] - } - } - } - - /** Handler for errors in any worker */ - function* handleWorkerError(payload: ErrorPayload) { - const { trackIndex, stemIndex, trackId, error, phase } = payload - // Check to make sure we haven't already errored for this track. - // This could happen if one of its stems failed. - // Double counting would terminate the loop too soon. - if ( - !errored.find( - (e) => e.trackIndex === trackIndex && e.stemIndex === stemIndex - ) - ) { - errored.push(payload) - } - - // Error this track - yield* put( - updateProgress({ - trackIndex, - stemIndex, - key: 'art', - progress: { status: ProgressStatus.ERROR } - }) - ) - yield* put( - updateProgress({ - trackIndex, - stemIndex, - key: 'audio', - progress: { status: ProgressStatus.ERROR } - }) - ) - - // Error this track's parent - if (stemIndex !== null) { - yield* put(responseChannel, { - type: 'ERROR', - payload: { - trackIndex, - stemIndex: null, - trackId: tracks[trackIndex].metadata.track_id, - error: new Error(`Stem ${stemIndex} failed to upload.`, { - cause: payload.error - }), - phase - } - }) - } else { - yield* put(make(Name.TRACK_UPLOAD_FAILURE, { kind })) - } - - console.error( - `Track ${trackId} (trackIndex: ${trackIndex}, stemIndex: ${stemIndex}) errored in the ${phase} phase:`, - error - ) - - // Report to sentry - const e = error instanceof Error ? error : new Error(String(error)) - yield* call(reportToSentry, { - name: 'UploadWorker', - error: e, - additionalInfo: { - trackId, - metadata: - stemIndex === null - ? tracks[trackIndex].metadata - : tracks[trackIndex].metadata.stems?.[stemIndex].metadata, - fileSize: tracks[trackIndex].file.size, - trackIndex, - stemIndex, - trackCount: tracks.length, - stemCount: stems, - phase, - kind - }, - feature: Feature.Upload - }) - } - - console.debug('Waiting for workers...') - const hasUnprocessedTracks = () => - published.length + errored.length < uploadRequestCount - const collectionUploadErrored = () => errored.length > 0 && isCollection - - // Wait for the workers to process every track and stem - // - unless uploading a collection and hit an error, in which case leave early - while (hasUnprocessedTracks() && !collectionUploadErrored()) { - const { type, payload } = yield* take(responseChannel) - if (type === 'UPLOADED') { - yield* call(handleUploaded, payload) - } else if (type === 'PUBLISHED') { - yield* call(handlePublished, payload) - } else if (type === 'ERROR') { - yield* call(handleWorkerError, payload) - } - } - - console.debug('Spinning down workers') - for (const worker of uploadWorkers) { - if (isTask(worker)) { - yield* cancel(worker) - } - } - for (const worker of publishWorkers) { - if (isTask(worker)) { - yield* cancel(worker) - } - } - yield* call(progressChannel.close) - yield* cancel(actionDispatcherTask) - - // Attempt to delete orphaned stems of failed tracks - for (const errorPayload of errored) { - const stemsToDelete = published.filter( - (p) => tracks[p.trackIndex].metadata.track_id === errorPayload.trackId - ) - if (stemsToDelete.length > 0) { - console.debug(`Cleaning up ${stemsToDelete.length} orphaned stems...`) - try { - yield* call( - deleteTracks, - stemsToDelete.map((t) => t.trackId) - ) - console.debug('Done cleaning up stems') - } catch (e) { - console.error('Failed to clean up orphaned stems:', e) - } - } - } - - // Attempt to delete all orphaned collection tracks, - // and throw as collection failures are all or nothing. - if (collectionUploadErrored()) { - console.debug(`Cleaning up ${published.length} orphaned tracks...`) - try { - yield* call( - deleteTracks, - published.map((t) => t.trackId) - ) - console.debug('Done cleaning up tracks') - } catch (e) { - console.error('Failed to clean up orphaned tracks:', e) - } - // Errors were reported to sentry earlier in the upload process. - // Throwing here so callers don't think they succeeded. - throw new Error('Failed to upload tracks for collection.', { - cause: errored - }) - } - - const publishedTrackIds = published - .filter((t) => t.stemIndex === null) - .sort((a, b) => a.trackIndex - b.trackIndex) - .map((p) => p.trackId) - - // If no tracks uploaded, we failed! - if (publishedTrackIds.length === 0) { - // Errors were reported to sentry earlier in the upload process. - // Throwing here so callers don't think they succeeded. - throw new Error('No tracks were successfully uploaded.', { cause: errored }) - } - - console.debug('Finished uploads') - return publishedTrackIds -} - -/** - * Uploads a collection. - * @param tracks The tracks of the collection - * @param userId The user uploading - * @param collectionMetadata misnomer - actually just the values from the form fields - * @param isAlbum Whether the collection is an album or not. - */ -export function* uploadCollection( - tracks: TrackForUpload[], - collectionMetadata: CollectionValues, - isAlbum: boolean, - uploadType: UploadType -) { - const sdk = yield* getSDK() - - yield waitForAccount() - const queryClient = yield* getContext('queryClient') - const userId = (yield* call(queryCurrentUserId))! - // This field will get replaced - let albumTrackPrice: number | undefined - - // If the collection is a premium album, this will populate the premium metadata (price/splits/etc) - if ( - isAlbum && - isContentUSDCPurchaseGated(collectionMetadata.stream_conditions) - ) { - // albumTrackPrice will be parsed out of the collection metadata, so we keep a copy here - albumTrackPrice = - collectionMetadata.stream_conditions?.usdc_purchase.albumTrackPrice - collectionMetadata.stream_conditions = yield* call( - getUSDCMetadata, - collectionMetadata.stream_conditions - ) - } - - // Propagate the collection metadata to the tracks - for (const track of tracks) { - track.metadata = yield* call( - combineMetadata, - track.metadata, - collectionMetadata, - albumTrackPrice - ) - } - - // Upload the tracks - const trackIds = yield* call(handleUploads, { - tracks, - kind: isAlbum ? 'album' : 'playlist' - }) - - yield* call( - recordGatedTracks, - tracks.map((t) => t.metadata) - ) - - const playlistId = yield* call([ - sdk.playlists, - sdk.playlists.generatePlaylistId - ]) - if (playlistId == null) { - throw new Error('Failed to get playlist ID') - } - - // Finally, create the playlist - yield* put( - confirmerActions.requestConfirmation( - `${collectionMetadata.playlist_name}_${Date.now()}`, - function* () { - try { - const { artwork } = collectionMetadata - - const coverArtFile = - artwork && 'file' in artwork ? (artwork?.file ?? null) : null - - if (isAlbum) { - // Create album - if (!coverArtFile) { - throw new Error('Cover art file is required for albums') - } - - yield* call([sdk.albums, sdk.albums.createAlbum], { - metadata: albumMetadataForCreateWithSDK( - collectionMetadata as unknown as Collection - ), - userId: Id.parse(userId), - albumId: Id.parse(playlistId), - trackIds: trackIds.map((id) => Id.parse(id)), - coverArtFile: fileToSdk(coverArtFile, 'cover_art') - }) - } else { - // Create playlist - yield* call([sdk.playlists, sdk.playlists.createPlaylist], { - metadata: playlistMetadataForCreateWithSDK( - collectionMetadata as unknown as Collection - ), - userId: Id.parse(userId), - playlistId: Id.parse(playlistId), - trackIds: trackIds.map((id) => Id.parse(id)), - coverArtFile: coverArtFile - ? fileToSdk(coverArtFile!, 'cover_art') - : undefined - }) - } - } catch (error) { - console.debug('Caught an error creating playlist') - if (playlistId) { - console.debug('Deleting playlist') - // If we got a playlist ID back, that means we - // created the playlist but adding tracks to it failed. So we must delete the playlist - yield* call([sdk.playlists, sdk.playlists.deletePlaylist], { - userId: Id.parse(userId), - playlistId: Id.parse(playlistId) - }) - console.debug('Playlist deleted successfully') - } - // Throw to trigger the fail callback - throw error instanceof Error - ? error - : new Error(`Error creating playlist: ${error}`) - } - - const { data = [] } = yield* call( - [sdk.full.playlists, sdk.full.playlists.getPlaylist], - { - playlistId: Id.parse(playlistId), - userId: OptionalId.parse(userId) - } - ) - const [collection] = transformAndCleanList( - data, - userCollectionMetadataFromSDK - ) - return collection - }, - function* (confirmedPlaylist: Collection) { - yield* put( - uploadActions.uploadTracksSucceeded({ - id: confirmedPlaylist.playlist_id - }) - ) - const user = yield* queryUser(userId) - - yield* call(primeCollectionDataSaga, [confirmedPlaylist]) - yield* put( - accountActions.addAccountPlaylist({ - id: confirmedPlaylist.playlist_id, - name: confirmedPlaylist.playlist_name, - is_album: confirmedPlaylist.is_album, - permalink: confirmedPlaylist.permalink!, - user: { - id: user!.user_id, - handle: user!.handle - } - }) - ) - yield* put( - libraryPageActions.addLocalCollection({ - collectionId: confirmedPlaylist.playlist_id, - isAlbum: confirmedPlaylist.is_album, - category: LibraryCategory.Favorite - }) - ) - queryClient.invalidateQueries({ - queryKey: getUserQueryKey(userId) - }) - }, - function* ({ error }) { - console.error( - `Create playlist call failed, deleting tracks: ${JSON.stringify( - trackIds - )}` - ) - try { - yield* all( - trackIds.map((id) => - sdk.tracks.deleteTrack({ - userId: Id.parse(userId), - trackId: Id.parse(id) - }) - ) - ) - console.debug('Deleted tracks.') - } catch (err) { - console.debug(`Could not delete all tracks: ${err}`) - } - // Handle error loses error details, so call reportToSentry explicitly - yield* call(reportToSentry, { - name: 'UploadCollection', - error, - additionalInfo: { - trackIds, - playlistId, - isAlbum, - collectionMetadata - }, - feature: Feature.Upload - }) - yield* put(uploadActions.uploadTracksFailed()) - yield* put( - errorActions.handleError({ - message: error.message, - shouldRedirect: true, - shouldReport: false - }) - ) - } - ) - ) -} - -/** - * Uploads any number of standalone tracks. - * @param tracks the tracks to upload - */ -export function* uploadMultipleTracks( - tracks: TrackForUpload[], - uploadType: UploadType -) { - const sdk = yield* getSDK() - - const queryClient = yield* getContext('queryClient') - - // Ensure the user is logged in - yield* call(waitForAccount) - - // Get the IDs ahead of time, so that stems can be associated. - const tracksWithIds = yield* all( - tracks.map((track) => - call(function* () { - const id = yield* call([sdk.tracks, sdk.tracks.generateTrackId]) - return { - ...track, - metadata: { - ...track.metadata, - track_id: id - } - } - }) - ) - ) - - // Upload tracks and stems parallel together - const trackIds = yield* call(handleUploads, { - tracks: tracksWithIds, - kind: 'tracks' - }) - - // Get true track metadatas back with retry logic - const newTracks = yield* retry( - 20, // max attempts - 2000, // delay between attempts in ms - function* () { - const tracks = yield* call(queryTracks, trackIds, { - force: true, - staleTime: 0 - }) - if (tracks.length === 0) { - throw new Error('No tracks found after uploading.') - } - return tracks - } - ) - - // Make sure track count changes for this user - const account = yield* call(queryAccountUser) - - queryClient.setQueryData(getUserQueryKey(account!.user_id), (prevUser) => - !prevUser - ? undefined - : { - ...prevUser, - track_count: newTracks.length - } - ) - - // At this point, the upload was success! The rest is metrics. - yield* put( - uploadActions.uploadTracksSucceeded({ - id: newTracks[0].track_id - }) - ) - - // Send analytics for any gated content - yield* call( - recordGatedTracks, - tracksWithIds.map((track) => track.metadata) - ) - - // If the hide remixes is turned on, send analytics event - for (let i = 0; i < newTracks.length; i += 1) { - const track = newTracks[i] - if (track.field_visibility?.remixes === false) { - yield* put( - make(Name.REMIX_HIDE, { - id: track.track_id, - handle: account!.handle - }) - ) - } - } - - // Send analytics for any remixes - for (const remixTrackData of newTracks) { - const remixTrack = remixTrackData - if ( - remixTrack.remix_of !== null && - Array.isArray(remixTrack.remix_of?.tracks) && - remixTrack.remix_of.tracks.length > 0 - ) { - yield* call(trackNewRemixEvent, remixTrack) - } - } - - // Refetch the user - queryClient.invalidateQueries({ - queryKey: getUserQueryKey(account!.user_id) - }) - - // Invalidate the uploader's profile tracks cache - queryClient.invalidateQueries({ - queryKey: [QUERY_KEYS.profileTracks, account!.handle] - }) - - for (const track of newTracks) { - const parentTrackId = track.remix_of?.tracks[0]?.parent_track_id - - // If it's a remix, invalidate the parent track's lineup and remixes page - if (parentTrackId) { - queryClient.invalidateQueries({ - queryKey: [QUERY_KEYS.trackPageLineup, parentTrackId] - }) - // Invalidate all possible combinations of remixes queries for the parent track - queryClient.invalidateQueries({ - queryKey: [QUERY_KEYS.remixes, parentTrackId], - exact: false - }) - } - } -} - -function* uploadTracksAsync( - action: ReturnType -) { - yield* call(waitForWrite) - const payload = action.payload - yield* put(uploadActions.uploadTracksRequested(payload)) - - const kind = (() => { - switch (payload.uploadType) { - case UploadType.PLAYLIST: - return 'playlist' - case UploadType.ALBUM: - return 'album' - case UploadType.INDIVIDUAL_TRACK: - case UploadType.INDIVIDUAL_TRACKS: - default: - return 'tracks' - } - })() - - try { - const recordEvent = make(Name.TRACK_UPLOAD_START_UPLOADING, { - count: payload.tracks.length, - kind - }) - yield* put(recordEvent) - - const tracks = payload.tracks - - // Prep the USDC purchase conditions - for (const trackUpload of tracks) { - trackUpload.metadata = yield* call( - addPremiumMetadata, - trackUpload.metadata - ) - } - - // Upload content. - const isAlbum = payload.uploadType === UploadType.ALBUM - const isCollection = payload.uploadType === UploadType.PLAYLIST || isAlbum - if (isCollection) { - yield* call( - uploadCollection, - tracks, - payload.metadata, - isAlbum, - payload.uploadType - ) - } else { - yield* call(uploadMultipleTracks, tracks, payload.uploadType) - } - yield* put( - make(Name.TRACK_UPLOAD_COMPLETE_UPLOAD, { - trackCount: payload.tracks.length, - kind - }) - ) - } catch (e) { - const error = e instanceof Error ? e : new Error(String(e)) - // Handle error loses error details, so call reportToSentry explicitly - yield* call(reportToSentry, { - error, - name: 'UploadTracks', - additionalInfo: { - kind, - tracks: payload.tracks - }, - feature: Feature.Upload - }) - yield* put(uploadActions.uploadTracksFailed()) - yield* put( - errorActions.handleError({ - message: error.message, - shouldRedirect: true, - shouldReport: false - }) - ) - } -} - -function* updateTrackAudioAsync( - action: ReturnType -) { - yield* call(waitForWrite) - const payload = action.payload - - try { - const tracks = yield* call(queryTracks, [payload.trackId]) - - if (!tracks[0]) { - throw new Error('Missing track for track audio replace.') - } - const track = tracks[0] - const sdk = yield* getSDK() - const userId = yield* call(queryCurrentUserId) - - if (!userId) { - throw new Error('No user id found during upload. Not signed in?') - } - - const metadata = trackMetadataForUploadToSdk({ - ...track, - ...(payload.metadata ?? {}) - }) - - const dispatch = yield* getContext('dispatch') - const handleProgressUpdate = (progress: Parameters[0]) => { - if (!('audio' in progress)) return - const { upload, transcode } = progress.audio - - const uploadVal = - transcode === undefined - ? (upload?.loaded ?? 0) / (upload?.total ?? 1) - : 1 - - dispatch( - replaceTrackProgressModalActions.set({ - progress: { upload: uploadVal, transcode: transcode?.decimal ?? 0 }, - error: false - }) - ) - } - - yield* put( - replaceTrackProgressModalActions.set({ - progress: { upload: 0, transcode: 0 }, - error: false - }) - ) - const updatedMetadata = yield* call( - [sdk.tracks, sdk.tracks.uploadTrackFiles], - { - userId: Id.parse(userId), - trackFile: fileToSdk(payload.file, 'audio'), - metadata, - onProgress: handleProgressUpdate - } - ) - - const newMetadata = { - ...payload.metadata, - bpm: metadata.isCustomBpm ? track.bpm : null, - duration: updatedMetadata.duration, - musical_key: metadata.isCustomMusicalKey ? metadata.musicalKey : null, - audio_analysis_error_count: 0, - orig_filename: updatedMetadata.origFilename || '', - orig_file_cid: updatedMetadata.origFileCid, - preview_cid: updatedMetadata.previewCid || '', - preview_start_seconds: updatedMetadata.previewStartSeconds ?? 0, - track_cid: updatedMetadata.trackCid || '', - audio_upload_id: updatedMetadata.audioUploadId - } - - yield* put( - cacheTracksActions.editTrack( - track.track_id, - newMetadata as TrackMetadataForUpload - ) - ) - - // If the track with replaced audio is in the queue, clear it - const queueOrder = yield* select(queueSelectors.getOrder) - if (queueOrder.map((item) => item.id).includes(track.track_id)) { - yield* put(queueActions.clear({})) - yield* put(playerActions.stop({})) - } - - // Delay to allow the user to see that the track replace upload has finished - yield* delay(1500) - - yield* put(replaceTrackProgressModalActions.close()) - yield* put(push(track.permalink)) - } catch (e) { - yield* put( - replaceTrackProgressModalActions.set({ - progress: { upload: 0, transcode: 0 }, - error: true - }) - ) - } -} - -function* watchUploadTracks() { - yield* takeLatest(uploadActions.UPLOAD_TRACKS, uploadTracksAsync) -} - -function* watchUpdateTrackAudio() { - yield* takeLatest(uploadActions.UPDATE_TRACK_AUDIO, updateTrackAudioAsync) -} - -export default function sagas() { - return [watchUploadTracks, watchUpdateTrackAudio] -} diff --git a/packages/web/src/pages/upload-page/pages/FinishPage.tsx b/packages/web/src/pages/upload-page/pages/FinishPage.tsx index b4ef71f1ae3..f1351752161 100644 --- a/packages/web/src/pages/upload-page/pages/FinishPage.tsx +++ b/packages/web/src/pages/upload-page/pages/FinishPage.tsx @@ -60,7 +60,7 @@ const ProgressIndicator = (props: { status?: ProgressStatus }) => { case ProgressStatus.COMPLETE: return case ProgressStatus.ERROR: - return + return default: return
} diff --git a/packages/web/src/store/sagas.ts b/packages/web/src/store/sagas.ts index af040671c02..394a65d0f1e 100644 --- a/packages/web/src/store/sagas.ts +++ b/packages/web/src/store/sagas.ts @@ -53,7 +53,6 @@ import savedCollectionsSagas from 'common/store/saved-collections/sagas' import searchAiBarSagas from 'common/store/search-ai-bar/sagas' import socialSagas from 'common/store/social/sagas' import tippingSagas from 'common/store/tipping/sagas' -import uploadSagas from 'common/store/upload/sagas' import firstUploadModalSagas from 'components/first-upload-modal/store/sagas' import passwordResetSagas from 'components/password-reset/store/sagas' import dashboardSagas from 'pages/dashboard-page/store/sagas' @@ -109,7 +108,6 @@ export default function* rootSaga() { trendingPageSagas(), trendingPlaylistSagas(), trendingUndergroundSagas(), - uploadSagas(), premiumTracksSagas(), exclusiveTracksSagas(), searchTracksLineupSagas(),