|
| 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