ultrasonic-app-subsonic-and.../ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/Downloader.kt

544 lines
18 KiB
Kotlin

package org.moire.ultrasonic.service
import android.net.wifi.WifiManager
import android.os.Handler
import android.os.Looper
import android.text.TextUtils
import androidx.lifecycle.MutableLiveData
import io.reactivex.rxjava3.disposables.CompositeDisposable
import org.koin.core.component.KoinComponent
import org.koin.core.component.inject
import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.domain.Artist
import org.moire.ultrasonic.domain.Track
import org.moire.ultrasonic.playback.LegacyPlaylistManager
import org.moire.ultrasonic.subsonic.ImageLoaderProvider
import org.moire.ultrasonic.util.CacheCleaner
import org.moire.ultrasonic.util.CancellableTask
import org.moire.ultrasonic.util.FileUtil
import org.moire.ultrasonic.util.LRUCache
import org.moire.ultrasonic.util.Settings
import org.moire.ultrasonic.util.Storage
import org.moire.ultrasonic.util.Util
import org.moire.ultrasonic.util.Util.safeClose
import timber.log.Timber
import java.io.IOException
import java.io.InputStream
import java.io.OutputStream
import java.util.Locale
import java.util.PriorityQueue
/**
* This class is responsible for maintaining the playlist and downloading
* its items from the network to the filesystem.
*
* TODO: Move entirely to subclass the Media3.DownloadService
*/
class Downloader(
private val storageMonitor: ExternalStorageMonitor,
private val legacyPlaylistManager: LegacyPlaylistManager,
) : KoinComponent {
// Dependencies
private val imageLoaderProvider: ImageLoaderProvider by inject()
private val activeServerProvider: ActiveServerProvider by inject()
private val mediaController: MediaPlayerController by inject()
var started: Boolean = false
var shouldStop: Boolean = false
private val downloadQueue = PriorityQueue<DownloadFile>()
private val activelyDownloading = mutableListOf<DownloadFile>()
// The generic list models expect a LiveData, so even though we are using Rx for many events
// surrounding playback the list of Downloads is published as LiveData.
val observableDownloads = MutableLiveData<List<DownloadFile>>()
// This cache helps us to avoid creating duplicate DownloadFile instances when showing Entries
private val downloadFileCache = LRUCache<Track, DownloadFile>(500)
private var handler: Handler = Handler(Looper.getMainLooper())
private var wifiLock: WifiManager.WifiLock? = null
private var backgroundPriorityCounter = 100
private val rxBusSubscription: CompositeDisposable = CompositeDisposable()
var downloadChecker = object : Runnable {
override fun run() {
try {
Timber.w("Checking Downloads")
checkDownloadsInternal()
} catch (all: Exception) {
Timber.e(all, "checkDownloads() failed.")
} finally {
if (!shouldStop) {
Handler(Looper.getMainLooper()).postDelayed(this, CHECK_INTERVAL)
} else {
shouldStop = false
}
}
}
}
fun onDestroy() {
stop()
rxBusSubscription.dispose()
clearBackground()
observableDownloads.value = listOf()
Timber.i("Downloader destroyed")
}
@Synchronized
fun start() {
started = true
// Start our loop
handler.postDelayed(downloadChecker, 100)
if (wifiLock == null) {
wifiLock = Util.createWifiLock(toString())
wifiLock?.acquire()
}
// Check downloads if the playlist changed
rxBusSubscription += RxBus.playlistObservable.subscribe {
checkDownloads()
}
}
fun stop() {
started = false
shouldStop = true
wifiLock?.release()
wifiLock = null
DownloadService.runningInstance?.notifyDownloaderStopped()
Timber.i("Downloader stopped")
}
fun checkDownloads() {
if (!started) {
start()
} else {
try {
handler.postDelayed(downloadChecker, 100)
} catch (all: Exception) {
Timber.w(
all,
"checkDownloads() can't run, maybe the Downloader is shutting down..."
)
}
}
}
@Synchronized
fun checkDownloadsInternal() {
if (!Util.isExternalStoragePresent() || !storageMonitor.isExternalStorageAvailable) {
return
}
if (legacyPlaylistManager.jukeboxMediaPlayer.isEnabled || !Util.isNetworkConnected()) {
return
}
Timber.v("Downloader checkDownloadsInternal checking downloads")
// Check the active downloads for failures or completions and remove them
// Store the result in a flag to know if changes have occurred
var listChanged = cleanupActiveDownloads()
// Check if need to preload more from playlist
val preloadCount = Settings.preloadCount
// Start preloading at the current playing song
var start = mediaController.currentMediaItemIndex
if (start == -1) start = 0
val end = (start + preloadCount).coerceAtMost(mediaController.mediaItemCount)
for (i in start until end) {
val download = legacyPlaylistManager.playlist[i]
// Set correct priority (the lower the number, the higher the priority)
download.priority = i
// Add file to queue if not in one of the queues already.
if (!download.isWorkDone &&
!activelyDownloading.contains(download) &&
!downloadQueue.contains(download) &&
download.shouldRetry()
) {
listChanged = true
downloadQueue.add(download)
}
}
// Fill up active List with waiting tasks
while (activelyDownloading.size < PARALLEL_DOWNLOADS && downloadQueue.size > 0) {
val task = downloadQueue.remove()
activelyDownloading.add(task)
startDownloadOnService(task)
listChanged = true
}
// Stop Executor service when done downloading
if (activelyDownloading.size == 0) {
stop()
}
if (listChanged) {
updateLiveData()
}
}
private fun updateLiveData() {
observableDownloads.postValue(downloads)
}
private fun startDownloadOnService(file: DownloadFile) {
if (file.isDownloading) return
file.prepare()
DownloadService.executeOnStartedMediaPlayerService {
FileUtil.createDirectoryForParent(file.pinnedFile)
file.isFailed = false
file.downloadTask = DownloadTask(file)
file.downloadTask!!.start()
}
}
/**
* Return true if modifications were made
*/
private fun cleanupActiveDownloads(): Boolean {
val oldSize = activelyDownloading.size
activelyDownloading.retainAll {
when {
it.isDownloading -> true
it.isFailed && it.shouldRetry() -> {
// Add it back to queue
downloadQueue.add(it)
false
}
else -> {
it.cleanup()
false
}
}
}
return (oldSize != activelyDownloading.size)
}
@get:Synchronized
val all: List<DownloadFile>
get() {
val temp: MutableList<DownloadFile> = ArrayList()
temp.addAll(activelyDownloading)
temp.addAll(downloadQueue)
temp.addAll(legacyPlaylistManager.playlist)
return temp.distinct().sorted()
}
/*
* Returns a list of all DownloadFiles that are currently downloading or waiting for download,
* including undownloaded files from the playlist.
*/
@get:Synchronized
val downloads: List<DownloadFile>
get() {
val temp: MutableList<DownloadFile> = ArrayList()
temp.addAll(activelyDownloading)
temp.addAll(downloadQueue)
temp.addAll(
legacyPlaylistManager.playlist.filter {
if (!it.isStatusInitialized) false
else when (it.status.value) {
DownloadStatus.DOWNLOADING -> true
else -> false
}
}
)
return temp.distinct().sorted()
}
@Synchronized
fun clearDownloadFileCache() {
downloadFileCache.clear()
}
@Synchronized
fun clearBackground() {
// Clear the pending queue
downloadQueue.clear()
// Cancel all active downloads with a low priority
for (download in activelyDownloading) {
if (download.priority >= 100) {
download.cancelDownload()
activelyDownloading.remove(download)
}
}
backgroundPriorityCounter = 100
}
@Synchronized
fun clearActiveDownloads() {
// Cancel all active downloads
for (download in activelyDownloading) {
download.cancelDownload()
}
activelyDownloading.clear()
updateLiveData()
}
@Synchronized
fun downloadBackground(songs: List<Track>, save: Boolean) {
// By using the counter we ensure that the songs are added in the correct order
for (song in songs) {
val file = song.getDownloadFile()
file.shouldSave = save
if (!file.isDownloading) {
file.priority = backgroundPriorityCounter++
downloadQueue.add(file)
}
}
checkDownloads()
}
@Synchronized
@Suppress("ReturnCount")
fun getDownloadFileForSong(song: Track): DownloadFile {
for (downloadFile in legacyPlaylistManager.playlist) {
if (downloadFile.track == song) {
return downloadFile
}
}
for (downloadFile in activelyDownloading) {
if (downloadFile.track == song) {
return downloadFile
}
}
for (downloadFile in downloadQueue) {
if (downloadFile.track == song) {
return downloadFile
}
}
var downloadFile = downloadFileCache[song]
if (downloadFile == null) {
downloadFile = DownloadFile(song, false)
downloadFileCache.put(song, downloadFile)
}
return downloadFile
}
companion object {
const val PARALLEL_DOWNLOADS = 3
const val CHECK_INTERVAL = 5000L
}
/**
* Extension function
* Gathers the download file for a given song, and modifies shouldSave if provided.
*/
private fun Track.getDownloadFile(save: Boolean? = null): DownloadFile {
return getDownloadFileForSong(this).apply {
if (save != null) this.shouldSave = save
}
}
private inner class DownloadTask(private val downloadFile: DownloadFile) : CancellableTask() {
val musicService = MusicServiceFactory.getMusicService()
override fun execute() {
downloadFile.downloadPrepared = false
var inputStream: InputStream? = null
var outputStream: OutputStream? = null
try {
if (Storage.isPathExists(downloadFile.pinnedFile)) {
Timber.i("%s already exists. Skipping.", downloadFile.pinnedFile)
downloadFile.status.postValue(DownloadStatus.PINNED)
return
}
if (Storage.isPathExists(downloadFile.completeFile)) {
var newStatus: DownloadStatus = DownloadStatus.DONE
if (downloadFile.shouldSave) {
if (downloadFile.isPlaying) {
downloadFile.saveWhenDone = true
} else {
Storage.rename(
downloadFile.completeFile,
downloadFile.pinnedFile
)
newStatus = DownloadStatus.PINNED
}
} else {
Timber.i(
"%s already exists. Skipping.",
downloadFile.completeFile
)
}
downloadFile.status.postValue(newStatus)
return
}
downloadFile.status.postValue(DownloadStatus.DOWNLOADING)
// Some devices seem to throw error on partial file which doesn't exist
val needsDownloading: Boolean
val duration = downloadFile.track.duration
val fileLength = Storage.getFromPath(downloadFile.partialFile)?.length ?: 0
needsDownloading = (
downloadFile.desiredBitRate == 0 || duration == null || duration == 0 || fileLength == 0L
)
if (needsDownloading) {
// Attempt partial HTTP GET, appending to the file if it exists.
val (inStream, isPartial) = musicService.getDownloadInputStream(
downloadFile.track, fileLength,
downloadFile.desiredBitRate,
downloadFile.shouldSave
)
inputStream = inStream
if (isPartial) {
Timber.i("Executed partial HTTP GET, skipping %d bytes", fileLength)
}
outputStream = Storage.getOrCreateFileFromPath(downloadFile.partialFile)
.getFileOutputStream(isPartial)
val len = inputStream.copyTo(outputStream) { totalBytesCopied ->
downloadFile.setProgress(totalBytesCopied)
}
Timber.i("Downloaded %d bytes to %s", len, downloadFile.partialFile)
inputStream.close()
outputStream.flush()
outputStream.close()
if (isCancelled) {
downloadFile.status.postValue(DownloadStatus.CANCELLED)
throw RuntimeException(
String.format(
Locale.ROOT, "Download of '%s' was cancelled",
downloadFile.track
)
)
}
if (downloadFile.track.artistId != null) {
cacheMetadata(downloadFile.track.artistId!!)
}
downloadAndSaveCoverArt()
}
if (downloadFile.isPlaying) {
downloadFile.completeWhenDone = true
} else {
if (downloadFile.shouldSave) {
Storage.rename(
downloadFile.partialFile,
downloadFile.pinnedFile
)
downloadFile.status.postValue(DownloadStatus.PINNED)
Util.scanMedia(downloadFile.pinnedFile)
} else {
Storage.rename(
downloadFile.partialFile,
downloadFile.completeFile
)
downloadFile.status.postValue(DownloadStatus.DONE)
}
}
} catch (all: Exception) {
outputStream.safeClose()
Storage.delete(downloadFile.completeFile)
Storage.delete(downloadFile.pinnedFile)
if (!isCancelled) {
downloadFile.isFailed = true
if (downloadFile.retryCount > 1) {
downloadFile.status.postValue(DownloadStatus.RETRYING)
--downloadFile.retryCount
} else if (downloadFile.retryCount == 1) {
downloadFile.status.postValue(DownloadStatus.FAILED)
--downloadFile.retryCount
}
Timber.w(all, "Failed to download '%s'.", downloadFile.track)
}
} finally {
inputStream.safeClose()
outputStream.safeClose()
CacheCleaner().cleanSpace()
checkDownloads()
}
}
override fun toString(): String {
return String.format(Locale.ROOT, "DownloadTask (%s)", downloadFile.track)
}
private fun cacheMetadata(artistId: String) {
// TODO: Right now it's caching the track artist.
// Once the albums are cached in db, we should retrieve the album,
// and then cache the album artist.
if (artistId.isEmpty()) return
var artist: Artist? =
activeServerProvider.getActiveMetaDatabase().artistsDao().get(artistId)
// If we are downloading a new album, and the user has not visited the Artists list
// recently, then the artist won't be in the database.
if (artist == null) {
val artists: List<Artist> = musicService.getArtists(true)
artist = artists.find {
it.id == artistId
}
}
// If we have found an artist, catch it.
if (artist != null) {
activeServerProvider.offlineMetaDatabase.artistsDao().insert(artist)
}
}
private fun downloadAndSaveCoverArt() {
try {
if (!TextUtils.isEmpty(downloadFile.track.coverArt)) {
// Download the largest size that we can display in the UI
imageLoaderProvider.getImageLoader().cacheCoverArt(downloadFile.track)
}
} catch (all: Exception) {
Timber.e(all, "Failed to get cover art.")
}
}
@Throws(IOException::class)
fun InputStream.copyTo(out: OutputStream, onCopy: (totalBytesCopied: Long) -> Any): Long {
var bytesCopied: Long = 0
val buffer = ByteArray(DEFAULT_BUFFER_SIZE)
var bytes = read(buffer)
while (!isCancelled && bytes >= 0) {
out.write(buffer, 0, bytes)
bytesCopied += bytes
onCopy(bytesCopied)
bytes = read(buffer)
}
return bytesCopied
}
}
}