From 24e8a3b83b68ce347b180ac997a45024f2efb806 Mon Sep 17 00:00:00 2001 From: Dylan Audius Date: Fri, 15 May 2026 01:17:18 -0700 Subject: [PATCH] feat(web): copy source tracks when duplicating a playlist Promotes the duplicate-playlist flow from metadata-only to a true duplicate that also copies every track from the source. - New DUPLICATE_PLAYLIST action carries the source playlist id, the composed form fields, the full source track id list, and an isAlbum flag. - New duplicatePlaylistSaga drives the full sequence: it dispatches the existing createPlaylist / createAlbum saga with the first source track as initTrackId, takes() the resulting CREATE_PLAYLIST_REQUESTED to learn the new playlist id, then sequentially dispatches addTrackToPlaylist({ silent: true }) for every remaining track with a small inter-dispatch delay so each saga sees the previous optimistic update. Closes with a single summary toast. - DuplicatePlaylistModal now dispatches DUPLICATE_PLAYLIST and exposes the actual track count to the user ("All N tracks will be copied") instead of the previous "tracks not copied" note. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../src/store/cache/collections/actions.ts | 25 +++++++ .../collections/duplicatePlaylistSaga.ts | 65 +++++++++++++++++++ .../store/cache/collections/webSagas.ts | 3 +- .../DuplicatePlaylistModal.tsx | 26 ++++++-- 4 files changed, 112 insertions(+), 7 deletions(-) create mode 100644 packages/web/src/common/store/cache/collections/duplicatePlaylistSaga.ts diff --git a/packages/common/src/store/cache/collections/actions.ts b/packages/common/src/store/cache/collections/actions.ts index 9947fbf6d81..fb153d4e95e 100644 --- a/packages/common/src/store/cache/collections/actions.ts +++ b/packages/common/src/store/cache/collections/actions.ts @@ -24,6 +24,8 @@ export const ORDER_PLAYLIST_FAILED = 'ORDER_PLAYLIST_FAILED' export const PUBLISH_PLAYLIST = 'PUBLISH_PLAYLIST' export const PUBLISH_PLAYLIST_FAILED = 'PUBLISH_PLAYLIST_FAILED' +export const DUPLICATE_PLAYLIST = 'DUPLICATE_PLAYLIST' + /** * @param initTrackId optional track id to pull artwork from. */ @@ -175,3 +177,26 @@ export function publishPlaylistFailed( ) { return { type: PUBLISH_PLAYLIST_FAILED, error, params, metadata } } + +export function duplicatePlaylist({ + sourcePlaylistId, + formFields, + trackIds, + source, + isAlbum +}: { + sourcePlaylistId: ID + formFields: Partial + trackIds: ID[] + source: string + isAlbum?: boolean +}) { + return { + type: DUPLICATE_PLAYLIST, + sourcePlaylistId, + formFields, + trackIds, + source, + isAlbum: !!isAlbum + } +} diff --git a/packages/web/src/common/store/cache/collections/duplicatePlaylistSaga.ts b/packages/web/src/common/store/cache/collections/duplicatePlaylistSaga.ts new file mode 100644 index 00000000000..9904fdd4ff9 --- /dev/null +++ b/packages/web/src/common/store/cache/collections/duplicatePlaylistSaga.ts @@ -0,0 +1,65 @@ +import { cacheCollectionsActions, toastActions } from '@audius/common/store' +import { call, delay, put, take, takeEvery } from 'typed-redux-saga' + +const { toast } = toastActions + +const INTER_DISPATCH_DELAY_MS = 30 + +const messages = { + duplicated: (count: number, isAlbum: boolean) => + `Duplicated ${isAlbum ? 'album' : 'playlist'} with ${count} ${ + count === 1 ? 'track' : 'tracks' + }`, + duplicatedNoTracks: (isAlbum: boolean) => + `Duplicated ${isAlbum ? 'album' : 'playlist'}` +} + +export function* duplicatePlaylistSaga() { + yield* takeEvery( + cacheCollectionsActions.DUPLICATE_PLAYLIST, + duplicatePlaylistWorker + ) +} + +function* duplicatePlaylistWorker( + action: ReturnType +) { + const { formFields, trackIds, source, isAlbum } = action + const initTrackId = trackIds.length > 0 ? trackIds[0] : null + + const createAction = isAlbum + ? cacheCollectionsActions.createAlbum + : cacheCollectionsActions.createPlaylist + + yield* put(createAction(formFields, source, initTrackId, 'route')) + + if (trackIds.length <= 1) { + if (trackIds.length === 0) { + yield* put(toast({ content: messages.duplicatedNoTracks(isAlbum) })) + } else { + yield* put(toast({ content: messages.duplicated(1, isAlbum) })) + } + return + } + + const requestedAction = yield* take( + cacheCollectionsActions.CREATE_PLAYLIST_REQUESTED + ) + const newPlaylistId = (requestedAction as unknown as { playlistId: number }) + .playlistId + + for (let i = 1; i < trackIds.length; i += 1) { + yield* put( + cacheCollectionsActions.addTrackToPlaylist(trackIds[i], newPlaylistId, { + silent: true + }) + ) + yield* delay(INTER_DISPATCH_DELAY_MS) + } + + yield* call(function* () { + yield* put( + toast({ content: messages.duplicated(trackIds.length, isAlbum) }) + ) + }) +} diff --git a/packages/web/src/common/store/cache/collections/webSagas.ts b/packages/web/src/common/store/cache/collections/webSagas.ts index f01b0a7bcc7..9760fa9d034 100644 --- a/packages/web/src/common/store/cache/collections/webSagas.ts +++ b/packages/web/src/common/store/cache/collections/webSagas.ts @@ -1,6 +1,7 @@ import commonSagas from './commonSagas' import { createPlaylistRequestedSaga } from './createPlaylistRequestedSaga' +import { duplicatePlaylistSaga } from './duplicatePlaylistSaga' export default function sagas() { - return [...commonSagas(), createPlaylistRequestedSaga] + return [...commonSagas(), createPlaylistRequestedSaga, duplicatePlaylistSaga] } diff --git a/packages/web/src/components/duplicate-playlist-modal/DuplicatePlaylistModal.tsx b/packages/web/src/components/duplicate-playlist-modal/DuplicatePlaylistModal.tsx index 060fabedeb8..f0aee0d77c3 100644 --- a/packages/web/src/components/duplicate-playlist-modal/DuplicatePlaylistModal.tsx +++ b/packages/web/src/components/duplicate-playlist-modal/DuplicatePlaylistModal.tsx @@ -28,7 +28,7 @@ import UploadArtwork from 'components/upload/UploadArtwork' import { useCollectionCoverArt } from 'hooks/useCollectionCoverArt' import { resizeImage } from 'utils/imageProcessingUtil' -const { createPlaylist, createAlbum } = cacheCollectionsActions +const { duplicatePlaylist } = cacheCollectionsActions const messages = { title: 'Duplicate Playlist', @@ -53,8 +53,10 @@ const messages = { newDescriptionPlaceholder: 'Describe what makes this special', cancel: 'Cancel', duplicate: 'Duplicate', - trackCopyNote: - 'Tracks are not copied automatically — you can add them after duplicating.', + trackCopyNote: (count: number) => + count === 0 + ? 'No tracks to copy from this playlist.' + : `All ${count} ${count === 1 ? 'track' : 'tracks'} will be copied to the new playlist.`, copySuffix: ' (Copy)' } @@ -179,8 +181,18 @@ export const DuplicatePlaylistModal = () => { sourceCollection.is_image_autogenerated ?? false } - const action = isAlbum ? createAlbum : createPlaylist - dispatch(action(formFields, CreatePlaylistSource.NAV, undefined, 'route')) + const sourceTrackIds = + sourceCollection.playlist_contents?.track_ids.map((t) => t.track) ?? [] + + dispatch( + duplicatePlaylist({ + sourcePlaylistId: sourceCollection.playlist_id, + formFields, + trackIds: sourceTrackIds, + source: CreatePlaylistSource.NAV, + isAlbum + }) + ) onClose() }, [ customArtwork, @@ -321,7 +333,9 @@ export const DuplicatePlaylistModal = () => { ) : null} - {messages.trackCopyNote} + {messages.trackCopyNote( + sourceCollection.playlist_contents?.track_ids.length ?? 0 + )} ) : null}