diff --git a/packages/common/src/store/cache/collections/actions.ts b/packages/common/src/store/cache/collections/actions.ts index cbc75f2a292..9947fbf6d81 100644 --- a/packages/common/src/store/cache/collections/actions.ts +++ b/packages/common/src/store/cache/collections/actions.ts @@ -102,8 +102,17 @@ export function editPlaylistFailed( return { type: EDIT_PLAYLIST_FAILED, error, params, metadata } } -export function addTrackToPlaylist(trackId: ID, playlistId: ID) { - return { type: ADD_TRACK_TO_PLAYLIST, trackId, playlistId } +export function addTrackToPlaylist( + trackId: ID, + playlistId: ID, + options?: { silent?: boolean } +) { + return { + type: ADD_TRACK_TO_PLAYLIST, + trackId, + playlistId, + silent: options?.silent ?? false + } } export function addTrackToPlaylistFailed( diff --git a/packages/common/src/store/ui/modals/add-tracks-by-url-modal/index.ts b/packages/common/src/store/ui/modals/add-tracks-by-url-modal/index.ts new file mode 100644 index 00000000000..b7ecf9ea476 --- /dev/null +++ b/packages/common/src/store/ui/modals/add-tracks-by-url-modal/index.ts @@ -0,0 +1,22 @@ +import { ID } from '../../../../models' +import { createModal } from '../createModal' + +export type AddTracksByUrlModalState = { + collectionId?: ID + isAlbum?: boolean +} + +const addTracksByUrlModal = createModal({ + reducerPath: 'AddTracksByUrlModal', + initialState: { + isOpen: false, + isAlbum: false + }, + sliceSelector: (state) => state.ui.modals +}) + +export const { + hook: useAddTracksByUrlModal, + reducer: addTracksByUrlModalReducer, + actions: addTracksByUrlModalActions +} = addTracksByUrlModal diff --git a/packages/common/src/store/ui/modals/index.ts b/packages/common/src/store/ui/modals/index.ts index 2de9f131c2e..3f1b34bb7a4 100644 --- a/packages/common/src/store/ui/modals/index.ts +++ b/packages/common/src/store/ui/modals/index.ts @@ -43,3 +43,4 @@ export * from './coin-success-modal' export * from './fan-club-details-modal' export * from './create-playlist-modal' export * from './duplicate-playlist-modal' +export * from './add-tracks-by-url-modal' diff --git a/packages/common/src/store/ui/modals/parentSlice.ts b/packages/common/src/store/ui/modals/parentSlice.ts index 709dfd15720..5f4f1d8d0a6 100644 --- a/packages/common/src/store/ui/modals/parentSlice.ts +++ b/packages/common/src/store/ui/modals/parentSlice.ts @@ -85,7 +85,8 @@ export const initialState: BasicModalsState = { VerificationSuccess: { isOpen: false }, VerificationError: { isOpen: false }, CreatePlaylistModal: { isOpen: false }, - DuplicatePlaylistModal: { isOpen: false } + DuplicatePlaylistModal: { isOpen: false }, + AddTracksByUrlModal: { isOpen: false } } const slice = createSlice({ diff --git a/packages/common/src/store/ui/modals/reducers.ts b/packages/common/src/store/ui/modals/reducers.ts index b338b219b62..e4b69013ba9 100644 --- a/packages/common/src/store/ui/modals/reducers.ts +++ b/packages/common/src/store/ui/modals/reducers.ts @@ -1,6 +1,7 @@ import { Action, combineReducers, Reducer } from '@reduxjs/toolkit' import { addCashModalReducer } from './add-cash-modal' +import { addTracksByUrlModalReducer } from './add-tracks-by-url-modal' import { albumTrackRemoveConfirmationModalReducer } from './album-track-remove-confirmation-modal' import { announcementModalReducer } from './announcement-modal' import { artistPickModalReducer } from './artist-pick-modal' @@ -96,7 +97,8 @@ const combinedReducers = combineReducers({ CoinSuccessModal: coinSuccessModalReducer, FanClubDetailsModal: fanClubDetailsModalReducer, CreatePlaylistModal: createPlaylistModalReducer, - DuplicatePlaylistModal: duplicatePlaylistModalReducer + DuplicatePlaylistModal: duplicatePlaylistModalReducer, + AddTracksByUrlModal: addTracksByUrlModalReducer }) /** diff --git a/packages/common/src/store/ui/modals/types.ts b/packages/common/src/store/ui/modals/types.ts index 9cd30c91f2d..9f6f31cdd4f 100644 --- a/packages/common/src/store/ui/modals/types.ts +++ b/packages/common/src/store/ui/modals/types.ts @@ -121,6 +121,7 @@ export type Modals = | 'VerificationError' | 'CreatePlaylistModal' | 'DuplicatePlaylistModal' + | 'AddTracksByUrlModal' export type BasicModalsState = { [modal in Modals]: BaseModalState diff --git a/packages/web/src/common/store/cache/collections/addTrackToPlaylistSaga.ts b/packages/web/src/common/store/cache/collections/addTrackToPlaylistSaga.ts index 4722b840b82..51671be2b29 100644 --- a/packages/web/src/common/store/cache/collections/addTrackToPlaylistSaga.ts +++ b/packages/web/src/common/store/cache/collections/addTrackToPlaylistSaga.ts @@ -165,11 +165,13 @@ function* addTrackToPlaylistAsync(action: AddTrackToPlaylistAction) { yield* put(event) - yield* put( - toast({ - content: messages.addedTrack(playlist.is_album ? 'album' : 'playlist') - }) - ) + if (!action.silent) { + yield* put( + toast({ + content: messages.addedTrack(playlist.is_album ? 'album' : 'playlist') + }) + ) + } } function* confirmAddTrackToPlaylist( diff --git a/packages/web/src/components/add-tracks-by-url-modal/AddTracksByUrlModal.tsx b/packages/web/src/components/add-tracks-by-url-modal/AddTracksByUrlModal.tsx new file mode 100644 index 00000000000..83b57255d2c --- /dev/null +++ b/packages/web/src/components/add-tracks-by-url-modal/AddTracksByUrlModal.tsx @@ -0,0 +1,289 @@ +import { useCallback, useMemo, useState } from 'react' + +import { userTrackMetadataFromSDK } from '@audius/common/adapters' +import { + useCollection, + useQueryContext, + useCurrentUserId +} from '@audius/common/api' +import { + cacheCollectionsActions, + toastActions, + useAddTracksByUrlModal +} from '@audius/common/store' +import { getErrorMessage, getPathFromTrackUrl } from '@audius/common/utils' +import { + Button, + Flex, + IconLink, + Modal, + ModalContent, + ModalFooter, + ModalHeader, + ModalTitle, + Text, + TextArea +} from '@audius/harmony' +import { OptionalId } from '@audius/sdk' +import { useDispatch } from 'react-redux' + +const { addTrackToPlaylist } = cacheCollectionsActions +const { toast } = toastActions + +const PLAYLIST_TRACK_LIMIT = 100 +const INTER_DISPATCH_DELAY_MS = 30 + +const messages = { + title: 'Add Tracks by URL', + helper: + 'Paste Audius track links — one per line, or separated by commas or tabs. Up to 100 tracks per playlist.', + textareaLabel: 'Track URLs', + textareaPlaceholder: + 'https://audius.co/artist/track-one\nhttps://audius.co/artist/track-two', + cancel: 'Cancel', + addTracks: 'Add Tracks', + noValidLinks: 'No valid Audius track links found.', + playlistFull: (limit: number) => + `This playlist already has ${limit} tracks — can't add more.`, + resolveFailed: 'Could not load tracks. Check your connection and try again.', + summary: ({ + added, + duplicates, + invalid, + unresolved, + overLimit + }: { + added: number + duplicates: number + invalid: number + unresolved: number + overLimit: number + }) => { + const parts: string[] = [] + if (added > 0) { + parts.push(`Added ${added} ${added === 1 ? 'track' : 'tracks'}`) + } + if (duplicates > 0) { + parts.push(`${duplicates} already in playlist`) + } + if (unresolved > 0) { + parts.push(`${unresolved} not found`) + } + if (invalid > 0) { + parts.push(`${invalid} invalid ${invalid === 1 ? 'link' : 'links'}`) + } + if (overLimit > 0) { + parts.push(`${overLimit} skipped (playlist limit reached)`) + } + return parts.length > 0 ? parts.join(' • ') : 'No tracks were added.' + } +} + +type ParseResult = { + permalinks: string[] + invalidCount: number +} + +const parseTrackUrls = (raw: string): ParseResult => { + const lines = raw + .split(/[\n\r,\t]+/) + .map((s) => s.trim()) + .filter(Boolean) + const permalinks: string[] = [] + const seen = new Set() + let invalidCount = 0 + for (const line of lines) { + const permalink = getPathFromTrackUrl(line) + if (permalink) { + if (!seen.has(permalink)) { + seen.add(permalink) + permalinks.push(permalink) + } + } else { + invalidCount += 1 + } + } + return { permalinks, invalidCount } +} + +export const AddTracksByUrlModal = () => { + const dispatch = useDispatch() + const { isOpen, onClose, onClosed, data } = useAddTracksByUrlModal() + const { collectionId } = data + const { audiusSdk } = useQueryContext() + const { data: currentUserId } = useCurrentUserId() + + const { data: existingTrackIdSet } = useCollection(collectionId, { + select: (c) => + new Set(c?.playlist_contents.track_ids.map((t) => t.track) ?? []) + }) + const currentTrackCount = existingTrackIdSet?.size ?? 0 + const remainingCapacity = Math.max( + 0, + PLAYLIST_TRACK_LIMIT - currentTrackCount + ) + + const [input, setInput] = useState('') + const [isSubmitting, setIsSubmitting] = useState(false) + + const parsed = useMemo(() => parseTrackUrls(input), [input]) + const livePreview = useMemo(() => { + if (!input.trim()) return null + return `${parsed.permalinks.length} valid${ + parsed.invalidCount > 0 ? ` • ${parsed.invalidCount} invalid` : '' + }` + }, [input, parsed]) + + const reset = useCallback(() => { + setInput('') + setIsSubmitting(false) + }, []) + + const handleClose = useCallback(() => { + onClose() + }, [onClose]) + + const handleClosed = useCallback(() => { + reset() + onClosed() + }, [onClosed, reset]) + + const handleSubmit = useCallback(async () => { + if (!collectionId) return + if (remainingCapacity === 0) { + dispatch(toast({ content: messages.playlistFull(PLAYLIST_TRACK_LIMIT) })) + return + } + const { permalinks, invalidCount } = parsed + if (permalinks.length === 0) { + dispatch(toast({ content: messages.noValidLinks })) + return + } + setIsSubmitting(true) + try { + const sdk = await audiusSdk() + const { data: sdkData = [] } = await sdk.tracks.getBulkTracks({ + permalink: permalinks, + userId: OptionalId.parse(currentUserId) + }) + const resolvedTracks = sdkData + .map((t) => userTrackMetadataFromSDK(t)) + .filter((t): t is NonNullable => t != null) + + const unresolved = permalinks.length - resolvedTracks.length + const seenIds = new Set() + const newTracks: typeof resolvedTracks = [] + let duplicates = 0 + for (const track of resolvedTracks) { + if (seenIds.has(track.track_id)) continue + seenIds.add(track.track_id) + if (existingTrackIdSet?.has(track.track_id)) { + duplicates += 1 + } else { + newTracks.push(track) + } + } + + const tracksToAdd = newTracks.slice(0, remainingCapacity) + const overLimit = newTracks.length - tracksToAdd.length + + for (const track of tracksToAdd) { + dispatch( + addTrackToPlaylist(track.track_id, collectionId, { silent: true }) + ) + // Space out dispatches so each saga's optimistic update lands + // before the next one reads the playlist state. + // eslint-disable-next-line no-await-in-loop + await new Promise((resolve) => + setTimeout(resolve, INTER_DISPATCH_DELAY_MS) + ) + } + + dispatch( + toast({ + content: messages.summary({ + added: tracksToAdd.length, + duplicates, + invalid: invalidCount, + unresolved, + overLimit + }) + }) + ) + setInput('') + onClose() + } catch (err) { + // eslint-disable-next-line no-console + console.error(getErrorMessage(err)) + dispatch(toast({ content: messages.resolveFailed })) + } finally { + setIsSubmitting(false) + } + }, [ + audiusSdk, + collectionId, + currentUserId, + dispatch, + existingTrackIdSet, + onClose, + parsed, + remainingCapacity + ]) + + const canSubmit = + !isSubmitting && parsed.permalinks.length > 0 && remainingCapacity > 0 + + return ( + + + } /> + + + + + {messages.helper} + +