/* * DownloadFile.kt * Copyright (C) 2009-2021 Ultrasonic developers * * Distributed under terms of the GNU GPLv3 license. */ package org.moire.ultrasonic.service import android.text.TextUtils import androidx.lifecycle.MutableLiveData import java.io.IOException import java.io.InputStream import java.io.OutputStream 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.Identifiable import org.moire.ultrasonic.domain.MusicDirectory import org.moire.ultrasonic.service.MusicServiceFactory.getMusicService import org.moire.ultrasonic.subsonic.ImageLoaderProvider import org.moire.ultrasonic.util.CacheCleaner import org.moire.ultrasonic.util.CancellableTask import org.moire.ultrasonic.util.StorageFile import org.moire.ultrasonic.util.FileUtil import org.moire.ultrasonic.util.Settings import org.moire.ultrasonic.util.Util import org.moire.ultrasonic.util.Util.safeClose import timber.log.Timber /** * This class represents a single Song or Video that can be downloaded. * * Terminology: * PinnedFile: A "pinned" song. Will stay in cache permanently * CompleteFile: A "downloaded" song. Will be quicker to be deleted if the cache is full * */ class DownloadFile( val song: MusicDirectory.Entry, save: Boolean ) : KoinComponent, Identifiable { val partialFile: String val completeFile: String private val saveFile: String = FileUtil.getSongFile(song) var shouldSave = save private var downloadTask: CancellableTask? = null var isFailed = false private var retryCount = MAX_RETRIES private val desiredBitRate: Int = Settings.maxBitRate var priority = 100 var downloadPrepared = false @Volatile private var isPlaying = false @Volatile private var saveWhenDone = false @Volatile private var completeWhenDone = false private val downloader: Downloader by inject() private val imageLoaderProvider: ImageLoaderProvider by inject() private val activeServerProvider: ActiveServerProvider by inject() val progress: MutableLiveData = MutableLiveData(0) val status: MutableLiveData init { val state: DownloadStatus partialFile = FileUtil.getParentPath(saveFile) + "/" + FileUtil.getPartialFile(FileUtil.getNameFromPath(saveFile)) completeFile = FileUtil.getParentPath(saveFile) + "/" + FileUtil.getCompleteFile(FileUtil.getNameFromPath(saveFile)) when { StorageFile.isPathExists(saveFile) -> { state = DownloadStatus.PINNED } StorageFile.isPathExists(completeFile) -> { state = DownloadStatus.DONE } else -> { state = DownloadStatus.IDLE } } status = MutableLiveData(state) } /** * Returns the effective bit rate. */ fun getBitRate(): Int { return if (song.bitRate == null) desiredBitRate else song.bitRate!! } @Synchronized fun prepare() { // It is necessary to signal that the download will begin shortly on another thread // so it won't get cleaned up accidentally downloadPrepared = true } @Synchronized fun download() { FileUtil.createDirectoryForParent(saveFile) isFailed = false downloadTask = DownloadTask() downloadTask!!.start() } @Synchronized fun cancelDownload() { downloadTask?.cancel() } val completeOrSaveFile: String get() = if (StorageFile.isPathExists(saveFile)) { saveFile } else { completeFile } val completeOrPartialFile: String get() = if (isCompleteFileAvailable) { completeOrSaveFile } else { partialFile } val isSaved: Boolean get() = StorageFile.isPathExists(saveFile) @get:Synchronized val isCompleteFileAvailable: Boolean get() = StorageFile.isPathExists(completeFile) || StorageFile.isPathExists(saveFile) @get:Synchronized val isWorkDone: Boolean get() = StorageFile.isPathExists(completeFile) && !shouldSave || StorageFile.isPathExists(saveFile) || saveWhenDone || completeWhenDone @get:Synchronized val isDownloading: Boolean get() = downloadPrepared || (downloadTask != null && downloadTask!!.isRunning) @get:Synchronized val isDownloadCancelled: Boolean get() = downloadTask != null && downloadTask!!.isCancelled fun shouldRetry(): Boolean { return (retryCount > 0) } fun delete() { cancelDownload() FileUtil.delete(partialFile) FileUtil.delete(completeFile) FileUtil.delete(saveFile) status.postValue(DownloadStatus.IDLE) Util.scanMedia(saveFile) } fun unpin() { val file = StorageFile.getFromPath(saveFile) ?: return StorageFile.rename(file, completeFile) status.postValue(DownloadStatus.DONE) } fun cleanup(): Boolean { var ok = true if (StorageFile.isPathExists(completeFile) || StorageFile.isPathExists(saveFile)) { ok = FileUtil.delete(partialFile) } if (StorageFile.isPathExists(saveFile)) { ok = ok and FileUtil.delete(completeFile) } return ok } fun setPlaying(isPlaying: Boolean) { if (!isPlaying) doPendingRename() this.isPlaying = isPlaying } // Do a pending rename after the song has stopped playing private fun doPendingRename() { try { if (saveWhenDone) { FileUtil.renameFile(completeFile, saveFile) saveWhenDone = false } else if (completeWhenDone) { if (shouldSave) { FileUtil.renameFile(partialFile, saveFile) Util.scanMedia(saveFile) } else { FileUtil.renameFile(partialFile, completeFile) } completeWhenDone = false } } catch (e: IOException) { Timber.w(e, "Failed to rename file %s to %s", completeFile, saveFile) } } override fun toString(): String { return String.format("DownloadFile (%s)", song) } private inner class DownloadTask : CancellableTask() { val musicService = getMusicService() override fun execute() { downloadPrepared = false var inputStream: InputStream? = null var outputStream: OutputStream? = null try { if (StorageFile.isPathExists(saveFile)) { Timber.i("%s already exists. Skipping.", saveFile) status.postValue(DownloadStatus.PINNED) return } if (StorageFile.isPathExists(completeFile)) { var newStatus: DownloadStatus = DownloadStatus.DONE if (shouldSave) { if (isPlaying) { saveWhenDone = true } else { FileUtil.renameFile(completeFile, saveFile) newStatus = DownloadStatus.PINNED } } else { Timber.i("%s already exists. Skipping.", completeFile) } status.postValue(newStatus) return } status.postValue(DownloadStatus.DOWNLOADING) // Some devices seem to throw error on partial file which doesn't exist val needsDownloading: Boolean val duration = song.duration val fileLength = StorageFile.getFromPath(partialFile)?.length ?: 0 needsDownloading = ( 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( song, fileLength, desiredBitRate, shouldSave ) inputStream = inStream if (isPartial) { Timber.i("Executed partial HTTP GET, skipping %d bytes", fileLength) } outputStream = StorageFile.getOrCreateFileFromPath(partialFile).getFileOutputStream(isPartial) val len = inputStream.copyTo(outputStream) { totalBytesCopied -> setProgress(totalBytesCopied) } Timber.i("Downloaded %d bytes to %s", len, partialFile) inputStream.close() outputStream.flush() outputStream.close() if (isCancelled) { status.postValue(DownloadStatus.ABORTED) throw Exception(String.format("Download of '%s' was cancelled", song)) } if (song.artistId != null) { cacheMetadata(song.artistId!!) } downloadAndSaveCoverArt() } if (isPlaying) { completeWhenDone = true } else { if (shouldSave) { FileUtil.renameFile(partialFile, saveFile) status.postValue(DownloadStatus.PINNED) Util.scanMedia(saveFile) } else { FileUtil.renameFile(partialFile, completeFile) status.postValue(DownloadStatus.DONE) } } } catch (all: Exception) { outputStream.safeClose() FileUtil.delete(completeFile) FileUtil.delete(saveFile) if (!isCancelled) { isFailed = true if (retryCount > 1) { status.postValue(DownloadStatus.RETRYING) --retryCount } else if (retryCount == 1) { status.postValue(DownloadStatus.FAILED) --retryCount } Timber.w(all, "Failed to download '%s'.", song) } } finally { inputStream.safeClose() outputStream.safeClose() CacheCleaner().cleanSpace() downloader.checkDownloads() } } override fun toString(): String { return String.format("DownloadTask (%s)", song) } 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 = 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(song.coverArt)) { // Download the largest size that we can display in the UI imageLoaderProvider.getImageLoader().cacheCoverArt(song) } } 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 } } private fun setProgress(totalBytesCopied: Long) { if (song.size != null) { progress.postValue((totalBytesCopied * 100 / song.size!!).toInt()) } } override fun compareTo(other: Identifiable) = compareTo(other as DownloadFile) fun compareTo(other: DownloadFile): Int { return priority.compareTo(other.priority) } override val id: String get() = song.id companion object { const val MAX_RETRIES = 5 } } enum class DownloadStatus { IDLE, DOWNLOADING, RETRYING, FAILED, ABORTED, DONE, PINNED, UNKNOWN }