Skip to content

Commit

Permalink
Update DownloadUtil.kt
Browse files Browse the repository at this point in the history
  • Loading branch information
reocat authored Nov 9, 2024
1 parent b2f766f commit 97de219
Showing 1 changed file with 134 additions and 98 deletions.
232 changes: 134 additions & 98 deletions app/src/main/java/com/dd3boh/outertune/playback/DownloadUtil.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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

Expand All @@ -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<ConnectivityManager>()!!
private val audioQuality by enumPreference(context, AudioQualityKey, AudioQuality.AUTO)
private val songUrlCache = HashMap<String, Pair<String, Long>>()
private val songUrlCache = ConcurrentHashMap<String, Pair<String, Long>>()

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)

Expand All @@ -172,7 +206,7 @@ class DownloadUtil @Inject constructor(
dataSourceFactory,
Executor(Runnable::run)
).apply {
maxParallelDownloads = 3
maxParallelDownloads = MAX_PARALLEL_DOWNLOADS
addListener(
ExoDownloadService.TerminalStateNotificationHelper(
context = context,
Expand All @@ -187,12 +221,14 @@ class DownloadUtil @Inject constructor(
fun getDownload(songId: String): Flow<Download?> = downloads.map { it[songId] }

fun download(songs: List<MediaMetadata>, 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)
Expand Down Expand Up @@ -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)
Expand Down

0 comments on commit 97de219

Please sign in to comment.