From 97de219b17565a25287c8d9df67f9180818eb0c0 Mon Sep 17 00:00:00 2001 From: reocat <111279389+reocat@users.noreply.github.com> Date: Sat, 9 Nov 2024 13:04:42 +0300 Subject: [PATCH] Update DownloadUtil.kt --- .../dd3boh/outertune/playback/DownloadUtil.kt | 232 ++++++++++-------- 1 file changed, 134 insertions(+), 98 deletions(-) diff --git a/app/src/main/java/com/dd3boh/outertune/playback/DownloadUtil.kt b/app/src/main/java/com/dd3boh/outertune/playback/DownloadUtil.kt index 19bc265dc..f87306df0 100644 --- a/app/src/main/java/com/dd3boh/outertune/playback/DownloadUtil.kt +++ b/app/src/main/java/com/dd3boh/outertune/playback/DownloadUtil.kt @@ -24,6 +24,7 @@ import com.dd3boh.outertune.models.MediaMetadata import com.dd3boh.outertune.utils.enumPreference import com.zionhuang.innertube.YouTube import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow @@ -32,11 +33,13 @@ import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext import okhttp3.OkHttpClient import java.time.Instant import java.time.ZoneOffset +import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.Executor +import java.util.concurrent.TimeUnit import javax.inject.Inject import javax.inject.Singleton @@ -46,122 +49,153 @@ class DownloadUtil @Inject constructor( private val database: MusicDatabase, private val databaseProvider: DatabaseProvider, @DownloadCache private val downloadCache: SimpleCache, + private val ioDispatcher: CoroutineDispatcher = Dispatchers.IO ) { + companion object { + private const val URL_CACHE_DURATION = TimeUnit.HOURS.toMillis(1) + private const val DEFAULT_CONTENT_LENGTH = 10_000_000L + private const val MAX_PARALLEL_DOWNLOADS = 3 + private const val LOCAL_SONG_PREFIX = "LA" + } + private val connectivityManager = context.getSystemService()!! private val audioQuality by enumPreference(context, AudioQualityKey, AudioQuality.AUTO) - private val songUrlCache = HashMap>() + private val songUrlCache = ConcurrentHashMap>() - private fun isNetworkAvailable(): Boolean { - val network = connectivityManager.activeNetwork ?: return false - val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false - return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) - } + // Reusable OkHttpClient instance + private val okHttpClient = OkHttpClient.Builder() + .proxy(YouTube.proxy) + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .build() + + private val isNetworkAvailable: Boolean + get() = connectivityManager.activeNetwork?.let { network -> + connectivityManager.getNetworkCapabilities(network) + ?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + } ?: false private val dataSourceFactory = ResolvingDataSource.Factory( - OkHttpDataSource.Factory( - OkHttpClient.Builder() - .proxy(YouTube.proxy) - .build() - ) + OkHttpDataSource.Factory(okHttpClient) ) { dataSpec -> val mediaId = dataSpec.key ?: error("No media id") - // Check if this is a downloaded song first - val download = downloadManager.downloadIndex.getDownload(mediaId) - if (download?.state == Download.STATE_COMPLETED) { - return@Factory dataSpec + when { + // Check completed downloads first + downloadManager.downloadIndex.getDownload(mediaId)?.state == Download.STATE_COMPLETED -> { + dataSpec + } + // Handle local songs + mediaId.startsWith(LOCAL_SONG_PREFIX) -> { + throw PlaybackException( + "Local songs are non-downloadable", + null, + PlaybackException.ERROR_CODE_IO_FILE_NOT_FOUND + ) + } + // Check network availability + !isNetworkAvailable -> { + throw PlaybackException( + "No network connection available and song not downloaded", + null, + PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED + ) + } + else -> { + handleMediaResolution(mediaId, dataSpec) + } } + } - // Handle local songs - if (mediaId.startsWith("LA")) { - throw PlaybackException( - "Local songs are non-downloadable", - null, - PlaybackException.ERROR_CODE_IO_FILE_NOT_FOUND - ) - } + private suspend fun handleMediaResolution(mediaId: String, dataSpec: ResolvingDataSource.DataSpec) = + withContext(ioDispatcher) { + // Check URL cache first + songUrlCache[mediaId]?.takeIf { it.second > System.currentTimeMillis() }?.let { + return@withContext dataSpec.withUri(it.first.toUri()) + } - // Check network availability for non-downloaded songs - if (!isNetworkAvailable()) { - throw PlaybackException( - "No network connection available and song not downloaded", - null, - PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED - ) - } + try { + val playedFormat = database.format(mediaId).first() + val playerResponse = YouTube.player(mediaId, registerPlayback = false).getOrThrow() - // Check URL cache - songUrlCache[mediaId]?.takeIf { it.second > System.currentTimeMillis() }?.let { - return@Factory dataSpec.withUri(it.first.toUri()) - } + checkPlayabilityStatus(playerResponse) + + val format = selectAudioFormat(playerResponse, playedFormat) + .copy(url = "${format.url}&range=0-${format.contentLength ?: DEFAULT_CONTENT_LENGTH}") - try { - val playedFormat = runBlocking(Dispatchers.IO) { database.format(mediaId).first() } - val playerResponse = runBlocking(Dispatchers.IO) { - YouTube.player(mediaId, registerPlayback = false) - }.getOrThrow() + updateFormatInDatabase(mediaId, format, playerResponse) - if (playerResponse.playabilityStatus.status != "OK") { - throw PlaybackException( - playerResponse.playabilityStatus.reason, - null, - PlaybackException.ERROR_CODE_REMOTE_ERROR - ) + // Cache the URL + songUrlCache[mediaId] = format.url!! to (System.currentTimeMillis() + URL_CACHE_DURATION) + dataSpec.withUri(format.url!!.toUri()) + } catch (e: Exception) { + throw wrapException(e) } + } - val format = (if (playedFormat != null) { - playerResponse.streamingData?.adaptiveFormats?.find { it.itag == playedFormat.itag } - } else { - playerResponse.streamingData?.adaptiveFormats - ?.filter { it.isAudio } - ?.maxByOrNull { - it.bitrate * when (audioQuality) { - AudioQuality.AUTO -> if (connectivityManager.isActiveNetworkMetered) -1 else 1 - AudioQuality.HIGH -> 1 - AudioQuality.LOW -> -1 - } + (if (it.mimeType.startsWith("audio/webm")) 10240 else 0) - } - } ?: throw PlaybackException( - "No suitable format found", + private fun checkPlayabilityStatus(playerResponse: YouTube.PlayerResponse) { + if (playerResponse.playabilityStatus.status != "OK") { + throw PlaybackException( + playerResponse.playabilityStatus.reason, null, - PlaybackException.ERROR_CODE_DECODER_INIT_FAILED - )).let { - it.copy(url = "${it.url}&range=0-${it.contentLength ?: 10000000}") - } + PlaybackException.ERROR_CODE_REMOTE_ERROR + ) + } + } - // Update format in database - database.query { - upsert( - FormatEntity( - id = mediaId, - itag = format.itag, - mimeType = format.mimeType.split(";")[0], - codecs = format.mimeType.split("codecs=")[1].removeSurrounding("\""), - bitrate = format.bitrate, - sampleRate = format.audioSampleRate, - contentLength = format.contentLength!!, - loudnessDb = playerResponse.playerConfig?.audioConfig?.loudnessDb, - playbackUrl = playerResponse.playbackTracking?.videostatsPlaybackUrl?.baseUrl - ) - ) - } + private fun selectAudioFormat( + playerResponse: YouTube.PlayerResponse, + playedFormat: FormatEntity? + ): YouTube.StreamingData.Format { + return (if (playedFormat != null) { + playerResponse.streamingData?.adaptiveFormats?.find { it.itag == playedFormat.itag } + } else { + playerResponse.streamingData?.adaptiveFormats + ?.filter { it.isAudio } + ?.maxByOrNull { + it.bitrate * when (audioQuality) { + AudioQuality.AUTO -> if (connectivityManager.isActiveNetworkMetered) -1 else 1 + AudioQuality.HIGH -> 1 + AudioQuality.LOW -> -1 + } + (if (it.mimeType.startsWith("audio/webm")) 10240 else 0) + } + } ?: throw PlaybackException( + "No suitable format found", + null, + PlaybackException.ERROR_CODE_DECODER_INIT_FAILED + )) + } - songUrlCache[mediaId] = format.url!! to (System.currentTimeMillis() + 3600000) // 1 hour cache - dataSpec.withUri(format.url!!.toUri()) - } catch (e: Exception) { - throw when (e) { - is PlaybackException -> e - else -> PlaybackException( - "Error preparing media: ${e.message}", - e, - PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED + private suspend fun updateFormatInDatabase( + mediaId: String, + format: YouTube.StreamingData.Format, + playerResponse: YouTube.PlayerResponse + ) { + database.query { + upsert( + FormatEntity( + id = mediaId, + itag = format.itag, + mimeType = format.mimeType.split(";")[0], + codecs = format.mimeType.split("codecs=")[1].removeSurrounding("\""), + bitrate = format.bitrate, + sampleRate = format.audioSampleRate, + contentLength = format.contentLength!!, + loudnessDb = playerResponse.playerConfig?.audioConfig?.loudnessDb, + playbackUrl = playerResponse.playbackTracking?.videostatsPlaybackUrl?.baseUrl ) - } + ) } } - // Rest of the code remains the same... - // (keeping the existing download management code) + private fun wrapException(e: Exception): PlaybackException = when (e) { + is PlaybackException -> e + else -> PlaybackException( + "Error preparing media: ${e.message}", + e, + PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED + ) + } val downloadNotificationHelper = DownloadNotificationHelper(context, ExoDownloadService.CHANNEL_ID) @@ -172,7 +206,7 @@ class DownloadUtil @Inject constructor( dataSourceFactory, Executor(Runnable::run) ).apply { - maxParallelDownloads = 3 + maxParallelDownloads = MAX_PARALLEL_DOWNLOADS addListener( ExoDownloadService.TerminalStateNotificationHelper( context = context, @@ -187,12 +221,14 @@ class DownloadUtil @Inject constructor( fun getDownload(songId: String): Flow = downloads.map { it[songId] } fun download(songs: List, context: Context) { - if (!isNetworkAvailable()) return - songs.forEach { song -> download(song, context) } + if (!isNetworkAvailable) return + CoroutineScope(ioDispatcher).launch { + songs.forEach { song -> download(song, context) } + } } fun download(song: MediaMetadata, context: Context) { - if (!isNetworkAvailable()) return + if (!isNetworkAvailable) return val downloadRequest = DownloadRequest.Builder(song.id, song.id.toUri()) .setCustomCacheKey(song.id) @@ -236,7 +272,7 @@ class DownloadUtil @Inject constructor( } } - CoroutineScope(Dispatchers.IO).launch { + CoroutineScope(ioDispatcher).launch { if (download.state == Download.STATE_COMPLETED) { val updateTime = Instant.ofEpochMilli(download.updateTimeMs) .atZone(ZoneOffset.UTC)