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 9e0fe97 commit 3f620b8
Showing 1 changed file with 153 additions and 92 deletions.
245 changes: 153 additions & 92 deletions app/src/main/java/com/dd3boh/outertune/playback/DownloadUtil.kt
Original file line number Diff line number Diff line change
Expand Up @@ -12,27 +12,45 @@ import androidx.media3.datasource.cache.SimpleCache
import androidx.media3.datasource.okhttp.OkHttpDataSource
import androidx.media3.exoplayer.offline.Download
import androidx.media3.exoplayer.offline.DownloadManager
import androidx.media3.exoplayer.offline.DownloadNotificationHelper
import androidx.media3.exoplayer.offline.DownloadRequest
import androidx.media3.exoplayer.offline.DownloadService
import com.dd3boh.outertune.constants.AudioQuality
import com.dd3boh.outertune.constants.AudioQualityKey
import com.dd3boh.outertune.db.MusicDatabase
import com.dd3boh.outertune.db.entities.FormatEntity
import com.dd3boh.outertune.di.DownloadCache
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.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.update
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import okhttp3.OkHttpClient
import java.io.IOException
import java.time.Instant
import java.time.ZoneOffset
import java.util.concurrent.Executor
import javax.inject.Inject
import javax.inject.Singleton

@Singleton
class EnhancedDownloadUtil @Inject constructor(
class DownloadUtil @Inject constructor(
@ApplicationContext private val context: Context,
private val database: MusicDatabase,
private val databaseProvider: DatabaseProvider,
@DownloadCache private val downloadCache: SimpleCache,
) {
private val connectivityManager = context.getSystemService<ConnectivityManager>()!!
private val audioQuality by enumPreference(context, AudioQualityKey, AudioQuality.AUTO)
private val songUrlCache = HashMap<String, Pair<String, Long>>()

private fun isNetworkAvailable(): Boolean {
val network = connectivityManager.activeNetwork ?: return false
val capabilities = connectivityManager.getNetworkCapabilities(network) ?: return false
Expand All @@ -47,87 +65,103 @@ class EnhancedDownloadUtil @Inject constructor(
)
) { dataSpec ->
val mediaId = dataSpec.key ?: error("No media id")
// First check if the song is downloaded and available offline

// Check if this is a downloaded song first
val download = downloadManager.downloadIndex.getDownload(mediaId)
if (download?.state == Download.STATE_COMPLETED) {
return@Factory dataSpec
}

// If we're offline and the song isn't downloaded, throw an appropriate exception
// Handle local songs
if (mediaId.startsWith("LA")) {
throw PlaybackException(
"Local songs are non-downloadable",
null,
PlaybackException.ERROR_CODE_UNSPECIFIED
)
}

// Check network availability for non-downloaded songs
if (!isNetworkAvailable()) {
throw PlaybackException(
"No network connection and song not available offline",
"No network connection available and song not downloaded",
null,
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED
)
}

// Check URL cache
songUrlCache[mediaId]?.takeIf { it.second > System.currentTimeMillis() }?.let {
return@Factory dataSpec.withUri(it.first.toUri())
}

try {
// Handle local files differently
if (mediaId.startsWith("LA")) {
val localUri = database.getLocalSongUri(mediaId).first()
return@Factory dataSpec.withUri(localUri.toUri())
}
val playedFormat = runBlocking(Dispatchers.IO) { database.format(mediaId).first() }
val playerResponse = runBlocking(Dispatchers.IO) {
YouTube.player(mediaId, registerPlayback = false)
}.getOrThrow()

// Check cache first
songUrlCache[mediaId]?.takeIf { it.second > System.currentTimeMillis() }?.let {
return@Factory dataSpec.withUri(it.first.toUri())
if (playerResponse.playabilityStatus.status != "OK") {
throw PlaybackException(
playerResponse.playabilityStatus.reason,
null,
PlaybackException.ERROR_CODE_REMOTE_ERROR
)
}

// Fetch format information
val format = runBlocking(Dispatchers.IO) {
val playedFormat = database.format(mediaId).first()
val playerResponse = YouTube.player(mediaId, registerPlayback = false).getOrThrow()

when {
playerResponse.playabilityStatus.status != "OK" ->
throw PlaybackException(
playerResponse.playabilityStatus.reason,
null,
PlaybackException.ERROR_CODE_REMOTE_ERROR
)
playedFormat != null ->
playerResponse.streamingData?.adaptiveFormats?.find { it.itag == playedFormat.itag }
else -> selectBestFormat(playerResponse.streamingData?.adaptiveFormats)
}?.let {
it.copy(url = "${it.url}&range=0-${it.contentLength ?: 10000000}")
} ?: throw PlaybackException("No suitable format found", null, PlaybackException.ERROR_CODE_NO_SUITABLE_FORMAT)
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",
null,
PlaybackException.ERROR_CODE_NO_SUITABLE_FORMAT
)).let {
it.copy(url = "${it.url}&range=0-${it.contentLength ?: 10000000}")
}

// Update database with format information
updateFormatInDatabase(mediaId, format)
// 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
)
)
}

// Update cache and return
songUrlCache[mediaId] = format.url!! to (System.currentTimeMillis() + 3600000) // 1 hour cache
dataSpec.withUri(format.url!!.toUri())
} catch (e: Exception) {
throw when (e) {
is IOException -> PlaybackException(
"Network error while fetching media",
e,
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED
)
is PlaybackException -> e
else -> PlaybackException(
"Error preparing media",
"Error preparing media: ${e.message}",
e,
PlaybackException.ERROR_CODE_UNSPECIFIED
PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED
)
}
}
}

private fun selectBestFormat(formats: List<Format>?) = formats
?.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)
}

val downloadNotificationHelper = DownloadNotificationHelper(context, ExoDownloadService.CHANNEL_ID)

val downloadManager: DownloadManager = DownloadManager(
context,
databaseProvider,
Expand All @@ -136,60 +170,87 @@ class EnhancedDownloadUtil @Inject constructor(
Executor(Runnable::run)
).apply {
maxParallelDownloads = 3
addListener(DownloadManagerListener())
addListener(
ExoDownloadService.TerminalStateNotificationHelper(
context = context,
notificationHelper = downloadNotificationHelper,
nextNotificationId = ExoDownloadService.NOTIFICATION_ID + 1
)
)
}

private val _downloads = MutableStateFlow<Map<String, Download>>(emptyMap())
val downloads: Flow<Map<String, Download>> = _downloads
val downloads = MutableStateFlow<Map<String, Download>>(emptyMap())

fun getDownload(songId: String): Flow<Download?> = downloads.map { it[songId] }

suspend fun download(song: MediaMetadata) {
fun download(songs: List<MediaMetadata>, context: Context) {
if (!isNetworkAvailable()) {
throw IOException("No network connection available")
// Consider adding an error callback or throwing an exception here
return
}
songs.forEach { song -> download(song, context) }
}

val request = DownloadRequest.Builder(song.id, song.id.toUri())
fun download(song: MediaMetadata, context: Context) {
if (!isNetworkAvailable()) {
// Consider adding an error callback or throwing an exception here
return
}

val downloadRequest = DownloadRequest.Builder(song.id, song.id.toUri())
.setCustomCacheKey(song.id)
.setData(song.title.toByteArray())
.build()

downloadManager.addDownload(request)

DownloadService.sendAddDownload(
context,
ExoDownloadService::class.java,
downloadRequest,
false
)
}

private inner class DownloadManagerListener : DownloadManager.Listener {
override fun onDownloadChanged(
downloadManager: DownloadManager,
download: Download,
finalException: Exception?
) {
_downloads.value = _downloads.value.toMutableMap().apply {
put(download.request.id, download)
}

if (download.state == Download.STATE_COMPLETED) {
updateDownloadStatus(download)
}
}

override fun onDownloadRemoved(
downloadManager: DownloadManager,
download: Download
) {
_downloads.value = _downloads.value.toMutableMap().apply {
remove(download.request.id)
}
}
fun resumeDownloadsOnStart(context: Context) {
DownloadService.sendResumeDownloads(
context,
ExoDownloadService::class.java,
false
)
}

init {
// Initialize downloads state
val downloads = mutableMapOf<String, Download>()
downloadManager.downloadIndex.getDownloads().use { cursor ->
while (cursor.moveToNext()) {
downloads[cursor.download.request.id] = cursor.download
}
val result = mutableMapOf<String, Download>()
val cursor = downloadManager.downloadIndex.getDownloads()
while (cursor.moveToNext()) {
result[cursor.download.request.id] = cursor.download
}
_downloads.value = downloads
downloads.value = result

downloadManager.addListener(
object : DownloadManager.Listener {
override fun onDownloadChanged(
downloadManager: DownloadManager,
download: Download,
finalException: Exception?
) {
downloads.update { map ->
map.toMutableMap().apply {
set(download.request.id, download)
}
}

CoroutineScope(Dispatchers.IO).launch {
if (download.state == Download.STATE_COMPLETED) {
val updateTime = Instant.ofEpochMilli(download.updateTimeMs)
.atZone(ZoneOffset.UTC)
.toLocalDateTime()
database.updateDownloadStatus(download.request.id, updateTime)
} else {
database.updateDownloadStatus(download.request.id, null)
}
}
}
}
)
}
}
}

0 comments on commit 3f620b8

Please sign in to comment.