Skip to content

Commit 2a42659

Browse files
dylanjeffersclaude
andcommitted
feat(web): add tracks to a playlist by pasting Audius URLs
Adds a new "Add Tracks by URL" affordance to the owner action row on playlist detail pages. Clicking it opens a modal where the user pastes Audius track links — line, comma, or tab separated — and submits to batch-add them to the current playlist. Albums and DDEX-imported collections are intentionally excluded. Implementation details: - New `AddTracksByUrlModal` Redux modal slice (createModal helper) wired through types/parentSlice/reducers/index. - New `AddTracksByUrlModal` component: - Parses pasted text into a deduped list of permalinks via `getPathFromTrackUrl`. - Resolves them in one round-trip via `sdk.tracks.getBulkTracks`. - Filters out tracks already in the playlist, enforces a 100-track cap, and reports invalid/unresolved/duplicate/over-limit counts in a single summary toast. - Dispatches `addTrackToPlaylist` per track with a 30 ms gap so each saga's optimistic update reads the previous one's state. - `addTrackToPlaylist` now accepts `{ silent: true }` so the per-track "Added track to playlist" toast can be suppressed during batch adds; default behavior (single-track adds elsewhere) is unchanged. - New `IconLink` button in `OwnerActionButtons` opens the modal, prefilled with the current collection id. Hidden for albums and DDEX-imported collections. Scope notes: - Resolution uses the existing `addTrackToPlaylist` saga path (sequential dispatches with small delay). A future PR could replace this with a dedicated `addTracksToPlaylistBatch` saga that issues a single SDK update for cleaner semantics on large pastes. - Larger track-curation features from the spec (multi-select, range select, undo/redo, copy selected URLs, multi-row drag) are deferred to follow-up PRs. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 74f3c41 commit 2a42659

10 files changed

Lines changed: 363 additions & 10 deletions

File tree

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

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -102,8 +102,17 @@ export function editPlaylistFailed(
102102
return { type: EDIT_PLAYLIST_FAILED, error, params, metadata }
103103
}
104104

105-
export function addTrackToPlaylist(trackId: ID, playlistId: ID) {
106-
return { type: ADD_TRACK_TO_PLAYLIST, trackId, playlistId }
105+
export function addTrackToPlaylist(
106+
trackId: ID,
107+
playlistId: ID,
108+
options?: { silent?: boolean }
109+
) {
110+
return {
111+
type: ADD_TRACK_TO_PLAYLIST,
112+
trackId,
113+
playlistId,
114+
silent: options?.silent ?? false
115+
}
107116
}
108117

109118
export function addTrackToPlaylistFailed(
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { ID } from '../../../../models'
2+
import { createModal } from '../createModal'
3+
4+
export type AddTracksByUrlModalState = {
5+
collectionId?: ID
6+
isAlbum?: boolean
7+
}
8+
9+
const addTracksByUrlModal = createModal<AddTracksByUrlModalState>({
10+
reducerPath: 'AddTracksByUrlModal',
11+
initialState: {
12+
isOpen: false,
13+
isAlbum: false
14+
},
15+
sliceSelector: (state) => state.ui.modals
16+
})
17+
18+
export const {
19+
hook: useAddTracksByUrlModal,
20+
reducer: addTracksByUrlModalReducer,
21+
actions: addTracksByUrlModalActions
22+
} = addTracksByUrlModal

packages/common/src/store/ui/modals/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -43,3 +43,4 @@ export * from './coin-success-modal'
4343
export * from './fan-club-details-modal'
4444
export * from './create-playlist-modal'
4545
export * from './duplicate-playlist-modal'
46+
export * from './add-tracks-by-url-modal'

packages/common/src/store/ui/modals/parentSlice.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -85,7 +85,8 @@ export const initialState: BasicModalsState = {
8585
VerificationSuccess: { isOpen: false },
8686
VerificationError: { isOpen: false },
8787
CreatePlaylistModal: { isOpen: false },
88-
DuplicatePlaylistModal: { isOpen: false }
88+
DuplicatePlaylistModal: { isOpen: false },
89+
AddTracksByUrlModal: { isOpen: false }
8990
}
9091

9192
const slice = createSlice({

packages/common/src/store/ui/modals/reducers.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { Action, combineReducers, Reducer } from '@reduxjs/toolkit'
22

33
import { addCashModalReducer } from './add-cash-modal'
4+
import { addTracksByUrlModalReducer } from './add-tracks-by-url-modal'
45
import { albumTrackRemoveConfirmationModalReducer } from './album-track-remove-confirmation-modal'
56
import { announcementModalReducer } from './announcement-modal'
67
import { artistPickModalReducer } from './artist-pick-modal'
@@ -96,7 +97,8 @@ const combinedReducers = combineReducers({
9697
CoinSuccessModal: coinSuccessModalReducer,
9798
FanClubDetailsModal: fanClubDetailsModalReducer,
9899
CreatePlaylistModal: createPlaylistModalReducer,
99-
DuplicatePlaylistModal: duplicatePlaylistModalReducer
100+
DuplicatePlaylistModal: duplicatePlaylistModalReducer,
101+
AddTracksByUrlModal: addTracksByUrlModalReducer
100102
})
101103

102104
/**

packages/common/src/store/ui/modals/types.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -121,6 +121,7 @@ export type Modals =
121121
| 'VerificationError'
122122
| 'CreatePlaylistModal'
123123
| 'DuplicatePlaylistModal'
124+
| 'AddTracksByUrlModal'
124125

125126
export type BasicModalsState = {
126127
[modal in Modals]: BaseModalState

packages/web/src/common/store/cache/collections/addTrackToPlaylistSaga.ts

Lines changed: 7 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -165,11 +165,13 @@ function* addTrackToPlaylistAsync(action: AddTrackToPlaylistAction) {
165165

166166
yield* put(event)
167167

168-
yield* put(
169-
toast({
170-
content: messages.addedTrack(playlist.is_album ? 'album' : 'playlist')
171-
})
172-
)
168+
if (!action.silent) {
169+
yield* put(
170+
toast({
171+
content: messages.addedTrack(playlist.is_album ? 'album' : 'playlist')
172+
})
173+
)
174+
}
173175
}
174176

175177
function* confirmAddTrackToPlaylist(
Lines changed: 289 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,289 @@
1+
import { useCallback, useMemo, useState } from 'react'
2+
3+
import { userTrackMetadataFromSDK } from '@audius/common/adapters'
4+
import {
5+
useCollection,
6+
useQueryContext,
7+
useCurrentUserId
8+
} from '@audius/common/api'
9+
import {
10+
cacheCollectionsActions,
11+
toastActions,
12+
useAddTracksByUrlModal
13+
} from '@audius/common/store'
14+
import { getErrorMessage, getPathFromTrackUrl } from '@audius/common/utils'
15+
import {
16+
Button,
17+
Flex,
18+
IconLink,
19+
Modal,
20+
ModalContent,
21+
ModalFooter,
22+
ModalHeader,
23+
ModalTitle,
24+
Text,
25+
TextArea
26+
} from '@audius/harmony'
27+
import { OptionalId } from '@audius/sdk'
28+
import { useDispatch } from 'react-redux'
29+
30+
const { addTrackToPlaylist } = cacheCollectionsActions
31+
const { toast } = toastActions
32+
33+
const PLAYLIST_TRACK_LIMIT = 100
34+
const INTER_DISPATCH_DELAY_MS = 30
35+
36+
const messages = {
37+
title: 'Add Tracks by URL',
38+
helper:
39+
'Paste Audius track links — one per line, or separated by commas or tabs. Up to 100 tracks per playlist.',
40+
textareaLabel: 'Track URLs',
41+
textareaPlaceholder:
42+
'https://audius.co/artist/track-one\nhttps://audius.co/artist/track-two',
43+
cancel: 'Cancel',
44+
addTracks: 'Add Tracks',
45+
noValidLinks: 'No valid Audius track links found.',
46+
playlistFull: (limit: number) =>
47+
`This playlist already has ${limit} tracks — can't add more.`,
48+
resolveFailed: 'Could not load tracks. Check your connection and try again.',
49+
summary: ({
50+
added,
51+
duplicates,
52+
invalid,
53+
unresolved,
54+
overLimit
55+
}: {
56+
added: number
57+
duplicates: number
58+
invalid: number
59+
unresolved: number
60+
overLimit: number
61+
}) => {
62+
const parts: string[] = []
63+
if (added > 0) {
64+
parts.push(`Added ${added} ${added === 1 ? 'track' : 'tracks'}`)
65+
}
66+
if (duplicates > 0) {
67+
parts.push(`${duplicates} already in playlist`)
68+
}
69+
if (unresolved > 0) {
70+
parts.push(`${unresolved} not found`)
71+
}
72+
if (invalid > 0) {
73+
parts.push(`${invalid} invalid ${invalid === 1 ? 'link' : 'links'}`)
74+
}
75+
if (overLimit > 0) {
76+
parts.push(`${overLimit} skipped (playlist limit reached)`)
77+
}
78+
return parts.length > 0 ? parts.join(' • ') : 'No tracks were added.'
79+
}
80+
}
81+
82+
type ParseResult = {
83+
permalinks: string[]
84+
invalidCount: number
85+
}
86+
87+
const parseTrackUrls = (raw: string): ParseResult => {
88+
const lines = raw
89+
.split(/[\n\r,\t]+/)
90+
.map((s) => s.trim())
91+
.filter(Boolean)
92+
const permalinks: string[] = []
93+
const seen = new Set<string>()
94+
let invalidCount = 0
95+
for (const line of lines) {
96+
const permalink = getPathFromTrackUrl(line)
97+
if (permalink) {
98+
if (!seen.has(permalink)) {
99+
seen.add(permalink)
100+
permalinks.push(permalink)
101+
}
102+
} else {
103+
invalidCount += 1
104+
}
105+
}
106+
return { permalinks, invalidCount }
107+
}
108+
109+
export const AddTracksByUrlModal = () => {
110+
const dispatch = useDispatch()
111+
const { isOpen, onClose, onClosed, data } = useAddTracksByUrlModal()
112+
const { collectionId } = data
113+
const { audiusSdk } = useQueryContext()
114+
const { data: currentUserId } = useCurrentUserId()
115+
116+
const { data: existingTrackIdSet } = useCollection(collectionId, {
117+
select: (c) =>
118+
new Set<number>(c?.playlist_contents.track_ids.map((t) => t.track) ?? [])
119+
})
120+
const currentTrackCount = existingTrackIdSet?.size ?? 0
121+
const remainingCapacity = Math.max(
122+
0,
123+
PLAYLIST_TRACK_LIMIT - currentTrackCount
124+
)
125+
126+
const [input, setInput] = useState('')
127+
const [isSubmitting, setIsSubmitting] = useState(false)
128+
129+
const parsed = useMemo(() => parseTrackUrls(input), [input])
130+
const livePreview = useMemo(() => {
131+
if (!input.trim()) return null
132+
return `${parsed.permalinks.length} valid${
133+
parsed.invalidCount > 0 ? ` • ${parsed.invalidCount} invalid` : ''
134+
}`
135+
}, [input, parsed])
136+
137+
const reset = useCallback(() => {
138+
setInput('')
139+
setIsSubmitting(false)
140+
}, [])
141+
142+
const handleClose = useCallback(() => {
143+
onClose()
144+
}, [onClose])
145+
146+
const handleClosed = useCallback(() => {
147+
reset()
148+
onClosed()
149+
}, [onClosed, reset])
150+
151+
const handleSubmit = useCallback(async () => {
152+
if (!collectionId) return
153+
if (remainingCapacity === 0) {
154+
dispatch(toast({ content: messages.playlistFull(PLAYLIST_TRACK_LIMIT) }))
155+
return
156+
}
157+
const { permalinks, invalidCount } = parsed
158+
if (permalinks.length === 0) {
159+
dispatch(toast({ content: messages.noValidLinks }))
160+
return
161+
}
162+
setIsSubmitting(true)
163+
try {
164+
const sdk = await audiusSdk()
165+
const { data: sdkData = [] } = await sdk.tracks.getBulkTracks({
166+
permalink: permalinks,
167+
userId: OptionalId.parse(currentUserId)
168+
})
169+
const resolvedTracks = sdkData
170+
.map((t) => userTrackMetadataFromSDK(t))
171+
.filter((t): t is NonNullable<typeof t> => t != null)
172+
173+
const unresolved = permalinks.length - resolvedTracks.length
174+
const seenIds = new Set<number>()
175+
const newTracks: typeof resolvedTracks = []
176+
let duplicates = 0
177+
for (const track of resolvedTracks) {
178+
if (seenIds.has(track.track_id)) continue
179+
seenIds.add(track.track_id)
180+
if (existingTrackIdSet?.has(track.track_id)) {
181+
duplicates += 1
182+
} else {
183+
newTracks.push(track)
184+
}
185+
}
186+
187+
const tracksToAdd = newTracks.slice(0, remainingCapacity)
188+
const overLimit = newTracks.length - tracksToAdd.length
189+
190+
for (const track of tracksToAdd) {
191+
dispatch(
192+
addTrackToPlaylist(track.track_id, collectionId, { silent: true })
193+
)
194+
// Space out dispatches so each saga's optimistic update lands
195+
// before the next one reads the playlist state.
196+
// eslint-disable-next-line no-await-in-loop
197+
await new Promise((resolve) =>
198+
setTimeout(resolve, INTER_DISPATCH_DELAY_MS)
199+
)
200+
}
201+
202+
dispatch(
203+
toast({
204+
content: messages.summary({
205+
added: tracksToAdd.length,
206+
duplicates,
207+
invalid: invalidCount,
208+
unresolved,
209+
overLimit
210+
})
211+
})
212+
)
213+
setInput('')
214+
onClose()
215+
} catch (err) {
216+
// eslint-disable-next-line no-console
217+
console.error(getErrorMessage(err))
218+
dispatch(toast({ content: messages.resolveFailed }))
219+
} finally {
220+
setIsSubmitting(false)
221+
}
222+
}, [
223+
audiusSdk,
224+
collectionId,
225+
currentUserId,
226+
dispatch,
227+
existingTrackIdSet,
228+
onClose,
229+
parsed,
230+
remainingCapacity
231+
])
232+
233+
const canSubmit =
234+
!isSubmitting && parsed.permalinks.length > 0 && remainingCapacity > 0
235+
236+
return (
237+
<Modal
238+
isOpen={isOpen}
239+
onClose={handleClose}
240+
onClosed={handleClosed}
241+
size='medium'
242+
>
243+
<ModalHeader onClose={handleClose}>
244+
<ModalTitle title={messages.title} icon={<IconLink />} />
245+
</ModalHeader>
246+
<ModalContent>
247+
<Flex direction='column' gap='m'>
248+
<Text variant='body' color='subdued'>
249+
{messages.helper}
250+
</Text>
251+
<TextArea
252+
aria-label={messages.textareaLabel}
253+
placeholder={messages.textareaPlaceholder}
254+
value={input}
255+
onChange={(e) => setInput(e.target.value)}
256+
grows
257+
maxVisibleRows={8}
258+
/>
259+
<Flex justifyContent='space-between'>
260+
<Text variant='body' size='s' color='subdued'>
261+
{livePreview ?? ' '}
262+
</Text>
263+
<Text variant='body' size='s' color='subdued'>
264+
{`${currentTrackCount} / ${PLAYLIST_TRACK_LIMIT}`}
265+
</Text>
266+
</Flex>
267+
</Flex>
268+
</ModalContent>
269+
<ModalFooter>
270+
<Flex gap='xl' flex={1}>
271+
<Button variant='secondary' fullWidth onClick={handleClose}>
272+
{messages.cancel}
273+
</Button>
274+
<Button
275+
variant='primary'
276+
fullWidth
277+
isLoading={isSubmitting}
278+
disabled={!canSubmit}
279+
onClick={handleSubmit}
280+
>
281+
{messages.addTracks}
282+
</Button>
283+
</Flex>
284+
</ModalFooter>
285+
</Modal>
286+
)
287+
}
288+
289+
export default AddTracksByUrlModal

0 commit comments

Comments
 (0)