Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[PAY-2432][PAY-2462] Cancellable downloads #7512

Merged
merged 2 commits into from
Feb 8, 2024
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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
Expand Up @@ -3,14 +3,16 @@ import { ID } from '~/models/Identifiers'
import { createModal } from '../createModal'

export type WaitForDownloadModalState = {
contentId: ID
// List of track ids to download, if larger than 1
// the first track is the parent track id.
trackIds: ID[]
}

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

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

import {
Divider,
Expand All @@ -23,24 +26,32 @@ const messages = {
}

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

const { spacing } = useTheme()
const track = useSelector((state: CommonState) =>
getTrack(state, { id: contentId })
getTrack(state, { id: trackIds[0] })
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: seems a bit unsafe to assume the parent track ID is always gonna be in the first position. could pass in a second param to the drawer data. up to you.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

got u!

)
const trackName =
track?.orig_filename && track?.orig_filename?.length > 0
trackIds.length === 1 &&
track?.orig_filename &&
track?.orig_filename?.length > 0
? track.orig_filename
: 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
4 changes: 3 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,9 @@ 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({
trackIds: parentTrackId ? [parentTrackId, ...trackIds] : trackIds
})
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