-
Notifications
You must be signed in to change notification settings - Fork 43
[C-1065][C-1066] Primitive download and playback for tracks offline #2021
Changes from all commits
63cedeb
05e3252
770d752
1be797f
0ad847d
cec2111
fd78a3d
008ac47
0cd1b9f
4ffe791
1a857ae
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,22 @@ | ||
| import type { Track } from '@audius/common' | ||
| import { FeatureFlags } from '@audius/common' | ||
| import { useAsync } from 'react-use' | ||
|
|
||
| import { | ||
| getLocalAudioPath, | ||
| isAudioAvailableOffline | ||
| } from 'app/services/offline-downloader' | ||
|
|
||
| import { useFeatureFlag } from './useRemoteConfig' | ||
|
|
||
| export const useOfflineTrackUri = (track: Track | null) => { | ||
| const { isEnabled: isOfflineModeEnabled } = useFeatureFlag( | ||
| FeatureFlags.OFFLINE_MODE_ENABLED | ||
| ) | ||
| return useAsync(async () => { | ||
| if (!track || !isOfflineModeEnabled) return | ||
| if (!(await isAudioAvailableOffline(track))) return | ||
| const audioFilePath = getLocalAudioPath(track) | ||
| return `file://${audioFilePath}` | ||
| }, [track]).value | ||
| } |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| export * from './offline-downloader' | ||
| export * from './offline-storage' |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,106 @@ | ||
| import path from 'path' | ||
|
|
||
| import type { Track, UserMetadata } from '@audius/common' | ||
| import { | ||
| encodeHashId, | ||
| accountSelectors, | ||
| cacheTracksSelectors | ||
| } from '@audius/common' | ||
| import { uniq } from 'lodash' | ||
| import RNFS, { exists } from 'react-native-fs' | ||
|
|
||
| import { store } from 'app/store' | ||
|
|
||
| import { apiClient } from '../audius-api-client' | ||
|
|
||
| import { | ||
| getLocalAudioPath, | ||
| getLocalCoverArtPath, | ||
| getLocalTrackJsonPath | ||
| } from './offline-storage' | ||
| const { getUserId } = accountSelectors | ||
| const { getTrack } = cacheTracksSelectors | ||
|
|
||
| /** Main entrypoint - perform all steps required to complete a download */ | ||
| export const downloadTrack = async (trackId: number, collection: string) => { | ||
| const state = store.getState() | ||
| const track = getTrack(state, { id: trackId }) | ||
| if (!track) return false | ||
|
|
||
| await downloadCoverArt(track) | ||
| await tryDownloadTrackFromEachCreatorNode(track) | ||
| await writeTrackJson(track, collection) | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. What do we do if either of these steps fail?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Really, we should check if all of the assets exist before writing |
||
| } | ||
|
|
||
| /** Unlike mp3 and album art, here we overwrite even if the file exists to ensure we have the latest */ | ||
| const writeTrackJson = async (track: Track, collection: string) => { | ||
| const trackToWrite: Track = { | ||
| ...track, | ||
| offline: { | ||
| downloaded_from_collection: uniq([ | ||
| collection, | ||
| ...(track?.offline?.downloaded_from_collection ?? []) | ||
| ]), | ||
| download_completed_time: Date.now(), | ||
| last_verified_time: Date.now() | ||
| } | ||
| } | ||
|
|
||
| const pathToWrite = getLocalTrackJsonPath(track) | ||
| await RNFS.write(pathToWrite, JSON.stringify(trackToWrite)) | ||
| } | ||
|
|
||
| const downloadCoverArt = async (track: Track) => { | ||
| // TODO: computed _cover_art_sizes isn't necessarily populated | ||
| const coverArtUris = Object.values(track._cover_art_sizes) | ||
| await Promise.all( | ||
| coverArtUris.map(async (coverArtUri) => { | ||
| const destination = getLocalCoverArtPath(track, coverArtUri) | ||
| await downloadIfNotExists(coverArtUri, destination) | ||
| }) | ||
| ) | ||
| } | ||
|
|
||
| const tryDownloadTrackFromEachCreatorNode = async (track: Track) => { | ||
| const state = store.getState() | ||
| const user = ( | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Do we not already have this in the store?
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Oh, we probably do. I was teaching myself how to use the API 😅
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Actually, wait. Not necessarily. If we're downloading all of our favorites we may not have loaded all of them yet. For now, yes, but in the future we will start this process whenever the app is open (and possibly in the background as well). I think I currently have a mix of logic meant to work in the background and logic that doesn't. It's going to need some further separation and cleanup. |
||
| await apiClient.getUser({ | ||
| userId: track?.owner_id, | ||
| // @ts-ignore mismatch in an irrelevant part of state | ||
| currentUserId: getUserId(state) | ||
| }) | ||
| )[0] as UserMetadata | ||
| const encodedTrackId = encodeHashId(track.track_id) | ||
| const creatorNodeEndpoints = user.creator_node_endpoint.split(',') | ||
| const destination = getLocalAudioPath(track) | ||
|
|
||
| for (const creatorNodeEndpoint of creatorNodeEndpoints) { | ||
| const uri = `${creatorNodeEndpoint}/tracks/stream/${encodedTrackId}` | ||
| const statusCode = await downloadIfNotExists(uri, destination) | ||
| if (statusCode) { | ||
| return statusCode | ||
| } | ||
| } | ||
| } | ||
|
|
||
| /** Dowanload file at uri to destination unless there is already a file at that location or overwrite is true */ | ||
| const downloadIfNotExists = async ( | ||
|
amendelsohn marked this conversation as resolved.
|
||
| uri: string, | ||
| destination: string, | ||
| overwrite?: boolean | ||
| ) => { | ||
| if (!uri || !destination) return null | ||
| if (!overwrite && (await exists(destination))) { | ||
| return null | ||
| } | ||
|
|
||
| const destinationDirectory = path.dirname(destination) | ||
| await RNFS.mkdir(destinationDirectory) | ||
|
|
||
| const result = await RNFS.downloadFile({ | ||
| fromUrl: uri, | ||
| toFile: destination | ||
| })?.promise | ||
|
|
||
| return result?.statusCode ?? null | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,77 @@ | ||
| import path from 'path' | ||
|
|
||
| import type { Track } from '@audius/common' | ||
| import RNFS, { exists } from 'react-native-fs' | ||
|
|
||
| export const downloadsRoot = path.join(RNFS.CachesDirectoryPath, 'downloads') | ||
|
|
||
| export const getPathFromRoot = (string: string) => { | ||
| return string.replace(downloadsRoot, '~') | ||
| } | ||
|
|
||
| export const getLocalTracksRoot = () => { | ||
| return path.join(downloadsRoot, `/tracks`) | ||
| } | ||
|
|
||
| export const getLocalTrackDir = (track: Track): string => { | ||
| return path.join(getLocalTracksRoot(), track.track_id.toString()) | ||
| } | ||
|
|
||
| // Track Json | ||
|
|
||
| export const getLocalTrackJsonPath = (track: Track) => { | ||
| return path.join(getLocalTrackDir(track), `${track.track_id}.json`) | ||
| } | ||
|
|
||
| // Cover Art | ||
|
|
||
| export const getLocalCoverArtPath = (track: Track, uri: string) => { | ||
| return path.join(getLocalTrackDir(track), getArtFileNameFromUri(uri)) | ||
| } | ||
|
|
||
| export const getArtFileNameFromUri = (uri: string) => { | ||
| // This should be "150x150.jpg" or similar | ||
| return uri.split('/').slice(-1)[0] | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. what if we just named them all the same thing like "Artwork"? (more predictable)
Contributor
Author
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. these will be named by the image size like |
||
| } | ||
|
|
||
| // Audio | ||
|
|
||
| export const getLocalAudioPath = (track: Track): string => { | ||
| return path.join(getLocalTrackDir(track), `${track.track_id}.mp3`) | ||
| } | ||
|
|
||
| export const isAudioAvailableOffline = async (track: Track) => { | ||
| return await exists(getLocalAudioPath(track)) | ||
| } | ||
|
|
||
| // Storage management | ||
|
|
||
| /** Debugging method to clear all downloaded content */ | ||
| export const purgeAllDownloads = async () => { | ||
| console.log(`Before purge:`) | ||
| await readDirRec(downloadsRoot) | ||
| await RNFS.unlink(downloadsRoot) | ||
| await RNFS.mkdir(downloadsRoot) | ||
| console.log(`After purge:`) | ||
| await readDirRec(downloadsRoot) | ||
| } | ||
|
|
||
| /** Debugging method to read cached files */ | ||
| export const readDirRec = async (path: string) => { | ||
| const files = await RNFS.readDir(path) | ||
| if (files.length === 0) { | ||
| console.log(`${getPathFromRoot(path)} - empty`) | ||
| } | ||
| files.forEach((item) => { | ||
| if (item.isFile()) { | ||
| console.log(`${getPathFromRoot(item.path)} - ${item.size} bytes`) | ||
| } | ||
| }) | ||
| await Promise.all( | ||
| files.map(async (item) => { | ||
| if (item.isDirectory()) { | ||
| await readDirRec(item.path) | ||
| } | ||
| }) | ||
| ) | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| export const pathJoin = (...segments: (string | undefined)[]) => { | ||
|
amendelsohn marked this conversation as resolved.
|
||
| return segments.join('/') | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I don't think we are doing this anywhere else, all this logic feels like it might actually belong in sagas. But I'm pretty anti-saga these days so would love to hear other people's thoughts
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It felt weird writing it too. Currently, we usually call this from a component that has the state, but I wanted to keep it reusable for the potential background process. However, we won't be able to use the state their either. Keeping it simple for now, but open to ideas.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Discussed in standup. Seems like this is the way for now