Skip to content

Commit 24e8a3b

Browse files
dylanjeffersclaude
andcommitted
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) <noreply@anthropic.com>
1 parent 2a42659 commit 24e8a3b

4 files changed

Lines changed: 112 additions & 7 deletions

File tree

packages/common/src/store/cache/collections/actions.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,8 @@ export const ORDER_PLAYLIST_FAILED = 'ORDER_PLAYLIST_FAILED'
2424
export const PUBLISH_PLAYLIST = 'PUBLISH_PLAYLIST'
2525
export const PUBLISH_PLAYLIST_FAILED = 'PUBLISH_PLAYLIST_FAILED'
2626

27+
export const DUPLICATE_PLAYLIST = 'DUPLICATE_PLAYLIST'
28+
2729
/**
2830
* @param initTrackId optional track id to pull artwork from.
2931
*/
@@ -175,3 +177,26 @@ export function publishPlaylistFailed(
175177
) {
176178
return { type: PUBLISH_PLAYLIST_FAILED, error, params, metadata }
177179
}
180+
181+
export function duplicatePlaylist({
182+
sourcePlaylistId,
183+
formFields,
184+
trackIds,
185+
source,
186+
isAlbum
187+
}: {
188+
sourcePlaylistId: ID
189+
formFields: Partial<Collection>
190+
trackIds: ID[]
191+
source: string
192+
isAlbum?: boolean
193+
}) {
194+
return {
195+
type: DUPLICATE_PLAYLIST,
196+
sourcePlaylistId,
197+
formFields,
198+
trackIds,
199+
source,
200+
isAlbum: !!isAlbum
201+
}
202+
}
Lines changed: 65 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,65 @@
1+
import { cacheCollectionsActions, toastActions } from '@audius/common/store'
2+
import { call, delay, put, take, takeEvery } from 'typed-redux-saga'
3+
4+
const { toast } = toastActions
5+
6+
const INTER_DISPATCH_DELAY_MS = 30
7+
8+
const messages = {
9+
duplicated: (count: number, isAlbum: boolean) =>
10+
`Duplicated ${isAlbum ? 'album' : 'playlist'} with ${count} ${
11+
count === 1 ? 'track' : 'tracks'
12+
}`,
13+
duplicatedNoTracks: (isAlbum: boolean) =>
14+
`Duplicated ${isAlbum ? 'album' : 'playlist'}`
15+
}
16+
17+
export function* duplicatePlaylistSaga() {
18+
yield* takeEvery(
19+
cacheCollectionsActions.DUPLICATE_PLAYLIST,
20+
duplicatePlaylistWorker
21+
)
22+
}
23+
24+
function* duplicatePlaylistWorker(
25+
action: ReturnType<typeof cacheCollectionsActions.duplicatePlaylist>
26+
) {
27+
const { formFields, trackIds, source, isAlbum } = action
28+
const initTrackId = trackIds.length > 0 ? trackIds[0] : null
29+
30+
const createAction = isAlbum
31+
? cacheCollectionsActions.createAlbum
32+
: cacheCollectionsActions.createPlaylist
33+
34+
yield* put(createAction(formFields, source, initTrackId, 'route'))
35+
36+
if (trackIds.length <= 1) {
37+
if (trackIds.length === 0) {
38+
yield* put(toast({ content: messages.duplicatedNoTracks(isAlbum) }))
39+
} else {
40+
yield* put(toast({ content: messages.duplicated(1, isAlbum) }))
41+
}
42+
return
43+
}
44+
45+
const requestedAction = yield* take(
46+
cacheCollectionsActions.CREATE_PLAYLIST_REQUESTED
47+
)
48+
const newPlaylistId = (requestedAction as unknown as { playlistId: number })
49+
.playlistId
50+
51+
for (let i = 1; i < trackIds.length; i += 1) {
52+
yield* put(
53+
cacheCollectionsActions.addTrackToPlaylist(trackIds[i], newPlaylistId, {
54+
silent: true
55+
})
56+
)
57+
yield* delay(INTER_DISPATCH_DELAY_MS)
58+
}
59+
60+
yield* call(function* () {
61+
yield* put(
62+
toast({ content: messages.duplicated(trackIds.length, isAlbum) })
63+
)
64+
})
65+
}
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import commonSagas from './commonSagas'
22
import { createPlaylistRequestedSaga } from './createPlaylistRequestedSaga'
3+
import { duplicatePlaylistSaga } from './duplicatePlaylistSaga'
34

45
export default function sagas() {
5-
return [...commonSagas(), createPlaylistRequestedSaga]
6+
return [...commonSagas(), createPlaylistRequestedSaga, duplicatePlaylistSaga]
67
}

packages/web/src/components/duplicate-playlist-modal/DuplicatePlaylistModal.tsx

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ import UploadArtwork from 'components/upload/UploadArtwork'
2828
import { useCollectionCoverArt } from 'hooks/useCollectionCoverArt'
2929
import { resizeImage } from 'utils/imageProcessingUtil'
3030

31-
const { createPlaylist, createAlbum } = cacheCollectionsActions
31+
const { duplicatePlaylist } = cacheCollectionsActions
3232

3333
const messages = {
3434
title: 'Duplicate Playlist',
@@ -53,8 +53,10 @@ const messages = {
5353
newDescriptionPlaceholder: 'Describe what makes this special',
5454
cancel: 'Cancel',
5555
duplicate: 'Duplicate',
56-
trackCopyNote:
57-
'Tracks are not copied automatically — you can add them after duplicating.',
56+
trackCopyNote: (count: number) =>
57+
count === 0
58+
? 'No tracks to copy from this playlist.'
59+
: `All ${count} ${count === 1 ? 'track' : 'tracks'} will be copied to the new playlist.`,
5860
copySuffix: ' (Copy)'
5961
}
6062

@@ -179,8 +181,18 @@ export const DuplicatePlaylistModal = () => {
179181
sourceCollection.is_image_autogenerated ?? false
180182
}
181183

182-
const action = isAlbum ? createAlbum : createPlaylist
183-
dispatch(action(formFields, CreatePlaylistSource.NAV, undefined, 'route'))
184+
const sourceTrackIds =
185+
sourceCollection.playlist_contents?.track_ids.map((t) => t.track) ?? []
186+
187+
dispatch(
188+
duplicatePlaylist({
189+
sourcePlaylistId: sourceCollection.playlist_id,
190+
formFields,
191+
trackIds: sourceTrackIds,
192+
source: CreatePlaylistSource.NAV,
193+
isAlbum
194+
})
195+
)
184196
onClose()
185197
}, [
186198
customArtwork,
@@ -321,7 +333,9 @@ export const DuplicatePlaylistModal = () => {
321333
) : null}
322334
</Flex>
323335
<Text variant='body' size='s' color='subdued'>
324-
{messages.trackCopyNote}
336+
{messages.trackCopyNote(
337+
sourceCollection.playlist_contents?.track_ids.length ?? 0
338+
)}
325339
</Text>
326340
</Flex>
327341
) : null}

0 commit comments

Comments
 (0)