Skip to content

Commit

Permalink
[PAY-2432][PAY-2462] Cancellable downloads (#7512)
Browse files Browse the repository at this point in the history
  • Loading branch information
raymondjacobson authored Feb 8, 2024
1 parent 5eff264 commit 3fd97ab
Show file tree
Hide file tree
Showing 10 changed files with 218 additions and 110 deletions.
2 changes: 2 additions & 0 deletions packages/common/src/services/track-download/TrackDownload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ export type DownloadFile = { url: string; filename: string }
export type DownloadTrackArgs = {
files: DownloadFile[]
rootDirectoryName?: string
abortSignal?: AbortSignal
}

export class TrackDownload {
Expand All @@ -21,6 +22,7 @@ export class TrackDownload {
/**
* Download one or multiple tracks. rootDirectoryName must be supplied
* if downloading multiple tracks.
* Should be overridden by inheriting services/interfaces.
*/
async downloadTracks(_args: DownloadTrackArgs) {
throw new Error('downloadTrack not implemented')
Expand Down
3 changes: 3 additions & 0 deletions packages/common/src/store/social/tracks/actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ export const UNSET_ARTIST_PICK = 'SOCIAL/UNSET_ARTIST_PICK'

export const RECORD_LISTEN = 'SOCIAL/RECORD_LISTEN'
export const DOWNLOAD_TRACK = 'SOCIAL/DOWNLOAD_TRACK'
export const CANCEL_DOWNLOAD = 'SOCIAL/CANCEL_DOWNLOAD'
export const DOWNLOAD_FINISHED = 'SOCIAL/DOWNLOAD_FINISHED'

export const SHARE_TRACK = 'SOCIAL/SHARE_TRACK'
Expand Down Expand Up @@ -113,6 +114,8 @@ export const downloadTrack = createCustomAction(
})
)

export const cancelDownloads = createCustomAction(CANCEL_DOWNLOAD, () => {})

export const downloadFinished = createCustomAction(DOWNLOAD_FINISHED, () => {})

export const shareTrack = createCustomAction(
Expand Down
Original file line number Diff line number Diff line change
@@ -1,16 +1,20 @@
import { DownloadQuality } from '~/models'
import { ID } from '~/models/Identifiers'

import { createModal } from '../createModal'

export type WaitForDownloadModalState = {
contentId: ID
parentTrackId?: ID
trackIds: ID[]
quality: DownloadQuality
}

const waitForDownloadModal = createModal<WaitForDownloadModalState>({
reducerPath: 'WaitForDownloadModal',
initialState: {
isOpen: false,
contentId: -1
trackIds: [],
quality: DownloadQuality.MP3
},
sliceSelector: (state) => state.ui.modals
})
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,15 @@
import { useCallback } from 'react'

import { DownloadQuality } from '@audius/common/models'
import type { CommonState } from '@audius/common/store'
import {
cacheTracksSelectors,
tracksSocialActions,
useWaitForDownloadModal
} from '@audius/common/store'
import { getDownloadFilename } from '@audius/common/utils'
import { css } from '@emotion/native'
import { useSelector } from 'react-redux'
import { useDispatch, useSelector } from 'react-redux'

import {
Divider,
Expand All @@ -23,24 +28,33 @@ const messages = {
}

export const WaitForDownloadDrawer = () => {
const dispatch = useDispatch()
const {
data: { contentId },
data: { parentTrackId, trackIds, quality },
isOpen,
onClose,
onClosed
} = useWaitForDownloadModal()

const { spacing } = useTheme()
const track = useSelector((state: CommonState) =>
getTrack(state, { id: contentId })
getTrack(state, { id: parentTrackId ?? trackIds[0] })
)
const trackName =
track?.orig_filename && track?.orig_filename?.length > 0
? track.orig_filename
!parentTrackId && track?.orig_filename && track?.orig_filename?.length > 0
? getDownloadFilename({
filename: track.orig_filename,
isOriginal: quality === DownloadQuality.ORIGINAL
})
: track?.title

const handleClosed = useCallback(() => {
dispatch(tracksSocialActions.cancelDownloads())
onClosed()
}, [onClosed, dispatch])

return (
<Drawer isOpen={isOpen} onClose={onClose} onClosed={onClosed}>
<Drawer isOpen={isOpen} onClose={onClose} onClosed={handleClosed}>
<Flex p='xl' gap='xl' alignItems='center'>
<Flex direction='row' gap='s' justifyContent='center'>
<IconReceive color='default' />
Expand Down
6 changes: 5 additions & 1 deletion packages/mobile/src/screens/track-screen/DownloadSection.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -106,7 +106,11 @@ export const DownloadSection = ({ trackId }: { trackId: ID }) => {
// On mobile, show a toast instead of a tooltip
toast({ content: messages.followToDownload })
} else if (track && track.access.download) {
openWaitForDownloadModal({ contentId: parentTrackId ?? trackIds[0] })
openWaitForDownloadModal({
parentTrackId,
trackIds,
quality
})
dispatch(
socialTracksActions.downloadTrack({
trackIds,
Expand Down
30 changes: 22 additions & 8 deletions packages/mobile/src/services/track-download.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
import type { DownloadTrackArgs } from '@audius/common/services'
import { TrackDownload as TrackDownloadBase } from '@audius/common/services'
import { tracksSocialActions } from '@audius/common/store'
import type { Nullable } from '@audius/common/utils'
import { Platform, Share } from 'react-native'
import { zip } from 'react-native-zip-archive'
import type {
Expand All @@ -19,14 +18,14 @@ import { audiusBackendInstance } from './audius-backend-instance'

const { downloadFinished } = tracksSocialActions

let fetchTask: Nullable<StatefulPromise<FetchBlobResponse>> = null
let fetchTasks: StatefulPromise<FetchBlobResponse>[] = []

const audiusDownloadsDirectory = 'AudiusDownloads'

const cancelDownloadTask = () => {
if (fetchTask) {
fetchTask.cancel()
}
fetchTasks.forEach((task) => {
task.cancel()
})
}

/**
Expand All @@ -48,10 +47,11 @@ const downloadOne = async ({
const filePath = directory + '/' + filename

try {
fetchTask = RNFetchBlob.config(getFetchConfig(filePath)).fetch(
const fetchTask = RNFetchBlob.config(getFetchConfig(filePath)).fetch(
'GET',
fileUrl
)
fetchTasks = [fetchTask]

// TODO: The RNFetchBlob library is currently broken for download progress events on both platforms.
// fetchTask.progress({ interval: 250 }, (received, total) => {
Expand Down Expand Up @@ -92,12 +92,13 @@ const downloadMany = async ({
onFetchComplete?: (path: string) => Promise<void>
}) => {
try {
const responsePromises = files.map(async ({ url, filename }) =>
const responsePromises = files.map(({ url, filename }) =>
RNFetchBlob.config(getFetchConfig(directory + '/' + filename)).fetch(
'GET',
url
)
)
fetchTasks = responsePromises
const responses = await Promise.all(responsePromises)
if (!responses.every((response) => response.info().status === 200)) {
throw new Error('Download unsuccessful')
Expand All @@ -118,7 +119,11 @@ const downloadMany = async ({
}
}

const download = async ({ files, rootDirectoryName }: DownloadTrackArgs) => {
const download = async ({
files,
rootDirectoryName,
abortSignal
}: DownloadTrackArgs) => {
if (files.length === 0) return

dispatch(
Expand All @@ -127,6 +132,15 @@ const download = async ({ files, rootDirectoryName }: DownloadTrackArgs) => {
fileName: files[0].filename
})
)
if (abortSignal) {
abortSignal.onabort = () => {
cancelDownloadTask()
}
}
// TODO: Remove this method of canceling after the lossless
// feature set launches. The abort signal should be the way to do
// this task cancellation going forward. The corresponding slice
// may also be deleted.
dispatch(setFetchCancel(cancelDownloadTask))

const audiusDirectory =
Expand Down
151 changes: 91 additions & 60 deletions packages/web/src/common/store/social/tracks/sagas.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,15 @@ import {
removeNullable
} from '@audius/common/utils'
import { capitalize } from 'lodash'
import { call, select, takeEvery, put, fork } from 'typed-redux-saga'
import {
call,
select,
takeEvery,
put,
fork,
take,
cancel
} from 'typed-redux-saga'

import { make } from 'common/store/analytics/actions'
import { adjustUserField } from 'common/store/cache/users/sagas'
Expand Down Expand Up @@ -757,11 +765,13 @@ const getFilename = ({
function* downloadTracks({
tracks,
original,
rootDirectoryName
rootDirectoryName,
abortSignal
}: {
tracks: { trackId: ID; filename: string }[]
original?: boolean
rootDirectoryName?: string
abortSignal?: AbortSignal
}) {
const { trackId: parentTrackId } = tracks[0]
try {
Expand Down Expand Up @@ -792,7 +802,8 @@ function* downloadTracks({
})
yield* call(trackDownload.downloadTracks, {
files,
rootDirectoryName
rootDirectoryName,
abortSignal
})
} catch (e) {
console.error(
Expand All @@ -807,71 +818,91 @@ function* watchDownloadTrack() {
yield* takeEvery(
socialActions.DOWNLOAD_TRACK,
function* (action: ReturnType<typeof socialActions.downloadTrack>) {
const { trackIds, parentTrackId, original } = action
if (
trackIds === undefined ||
trackIds.length === 0 ||
(trackIds.length > 1 && !parentTrackId)
) {
console.error(
`Could not download track ${trackIds}: Invalid trackIds or missing parentTrackId`
)
return
}

yield* call(waitForRead)
const controller = new AbortController()
const task = yield* fork(function* () {
const { trackIds, parentTrackId, original } = action
if (
trackIds === undefined ||
trackIds.length === 0 ||
(trackIds.length > 1 && !parentTrackId)
) {
console.error(
`Could not download track ${trackIds}: Invalid trackIds or missing parentTrackId`
)
return
}

const getFeatureEnabled = yield* getContext('getFeatureEnabled')
const isLosslessDownloadsEnabled = yield* call(
getFeatureEnabled,
FeatureFlags.LOSSLESS_DOWNLOADS_ENABLED
)
if (!isLosslessDownloadsEnabled) {
yield* call(downloadTrackV1, action)
return
}
yield* call(waitForRead)

// Check if there is a logged in account and if not,
// wait for one so we can trigger the download immediately after
// logging in.
const accountUserId = yield* select(getUserId)
if (!accountUserId) {
yield* call(waitForValue, getUserId)
}
const getFeatureEnabled = yield* getContext('getFeatureEnabled')
const isLosslessDownloadsEnabled = yield* call(
getFeatureEnabled,
FeatureFlags.LOSSLESS_DOWNLOADS_ENABLED
)
if (!isLosslessDownloadsEnabled) {
yield* call(downloadTrackV1, action)
return
}

const mainTrackId = parentTrackId ?? trackIds[0]
const mainTrack = yield* select(getTrack, {
id: mainTrackId
})
if (!mainTrack) return
const userId = mainTrack?.owner_id
const user = yield* select(getUser, { id: userId })
if (!user) return
const rootDirectoryName = `${user.name} - ${mainTrack.title} (Audius)`
// Mobile typecheck complains if this array isn't typed
const tracks: { trackId: ID; filename: string }[] = []
// Check if there is a logged in account and if not,
// wait for one so we can trigger the download immediately after
// logging in.
const accountUserId = yield* select(getUserId)
if (!accountUserId) {
yield* call(waitForValue, getUserId)
}

for (const trackId of [...trackIds, parentTrackId].filter(
removeNullable
)) {
const track: Track | null = yield* select(getTrack, { id: trackId })
if (!track) return
const mainTrackId = parentTrackId ?? trackIds[0]
const mainTrack = yield* select(getTrack, {
id: mainTrackId
})
if (!mainTrack) {
console.error(
`Failed to download because no mainTrack ${mainTrackId}`
)
return
}
const userId = mainTrack?.owner_id
const user = yield* select(getUser, { id: userId })
if (!user) {
console.error(`Failed to download because no user ${userId}`)
return
}
const rootDirectoryName = `${user.name} - ${mainTrack.title} (Audius)`
// Mobile typecheck complains if this array isn't typed
const tracks: { trackId: ID; filename: string }[] = []

for (const trackId of [...trackIds, parentTrackId].filter(
removeNullable
)) {
const track: Track | null = yield* select(getTrack, { id: trackId })
if (!track) {
console.error(
`Skipping individual download because no track ${trackId}`
)
return
}

tracks.push({
trackId,
filename: getFilename({
track,
user,
original
tracks.push({
trackId,
filename: getFilename({
track,
user,
original
})
})
})
}
}

yield* call(downloadTracks, {
tracks,
original,
rootDirectoryName
yield* call(downloadTracks, {
tracks,
original,
rootDirectoryName,
abortSignal: controller.signal
})
})
yield* take(socialActions.CANCEL_DOWNLOAD)
controller.abort()
yield* cancel(task)
}
)
}
Expand Down
Loading

0 comments on commit 3fd97ab

Please sign in to comment.