/* * DownloadFile.kt * Copyright (C) 2009-2021 Ultrasonic developers * * Distributed under terms of the GNU GPLv3 license. */ package org.moire.ultrasonic.service import android.net.wifi.WifiManager.WifiLock import android.text.TextUtils import androidx.lifecycle.MutableLiveData import java.io.File import java.io.FileOutputStream import java.io.IOException import java.io.InputStream import java.io.OutputStream import java.io.RandomAccessFile import org.koin.core.component.KoinComponent import org.koin.core.component.inject 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.FileUtil import org.moire.ultrasonic.util.Util import timber.log.Timber /** * This class represents a singe Song or Video that can be downloaded. * * @author Sindre Mehus * @version $Id$ */ class DownloadFile( val song: MusicDirectory.Entry, private val save: Boolean ) : KoinComponent, Comparable { val partialFile: File val completeFile: File private val saveFile: File = FileUtil.getSongFile(song) private var downloadTask: CancellableTask? = null var isFailed = false private var retryCount = MAX_RETRIES private val desiredBitRate: Int = Util.getMaxBitRate() var priority = 100 @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() val progress: MutableLiveData = MutableLiveData(0) init { partialFile = File(saveFile.parent, FileUtil.getPartialFile(saveFile.name)) completeFile = File(saveFile.parent, FileUtil.getCompleteFile(saveFile.name)) } /** * Returns the effective bit rate. */ fun getBitRate(): Int { return if (song.bitRate == null) desiredBitRate else song.bitRate!! } @Synchronized fun download() { FileUtil.createDirectoryForParent(saveFile) isFailed = false downloadTask = DownloadTask() downloadTask!!.start() } @Synchronized fun cancelDownload() { if (downloadTask != null) { downloadTask!!.cancel() } } val completeOrSaveFile: File get() = if (saveFile.exists()) { saveFile } else { completeFile } val completeOrPartialFile: File get() = if (isCompleteFileAvailable) { completeOrSaveFile } else { partialFile } val isSaved: Boolean get() = saveFile.exists() @get:Synchronized val isCompleteFileAvailable: Boolean get() = saveFile.exists() || completeFile.exists() @get:Synchronized val isWorkDone: Boolean get() = saveFile.exists() || completeFile.exists() && !save || saveWhenDone || completeWhenDone @get:Synchronized val isDownloading: Boolean get() = downloadTask != null && downloadTask!!.isRunning @get:Synchronized val isDownloadCancelled: Boolean get() = downloadTask != null && downloadTask!!.isCancelled fun shouldSave(): Boolean { return save } fun shouldRetry(): Boolean { return (retryCount > 0) } fun delete() { cancelDownload() Util.delete(partialFile) Util.delete(completeFile) Util.delete(saveFile) Util.scanMedia(saveFile) } fun unpin() { if (saveFile.exists()) { if (!saveFile.renameTo(completeFile)) { Timber.w( "Renaming file failed. Original file: %s; Rename to: %s", saveFile.name, completeFile.name ) } } } fun cleanup(): Boolean { var ok = true if (completeFile.exists() || saveFile.exists()) { ok = Util.delete(partialFile) } if (saveFile.exists()) { ok = ok and Util.delete(completeFile) } return ok } // In support of LRU caching. fun updateModificationDate() { updateModificationDate(saveFile) updateModificationDate(partialFile) updateModificationDate(completeFile) } 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) { Util.renameFile(completeFile, saveFile) saveWhenDone = false } else if (completeWhenDone) { if (save) { Util.renameFile(partialFile, saveFile) Util.scanMedia(saveFile) } else { Util.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() { override fun execute() { var inputStream: InputStream? = null var outputStream: FileOutputStream? = null var wifiLock: WifiLock? = null try { wifiLock = Util.createWifiLock(toString()) wifiLock.acquire() if (saveFile.exists()) { Timber.i("%s already exists. Skipping.", saveFile) return } if (completeFile.exists()) { if (save) { if (isPlaying) { saveWhenDone = true } else { Util.renameFile(completeFile, saveFile) } } else { Timber.i("%s already exists. Skipping.", completeFile) } return } val musicService = getMusicService() // Some devices seem to throw error on partial file which doesn't exist val needsDownloading: Boolean val duration = song.duration var fileLength: Long = 0 if (!partialFile.exists()) { fileLength = partialFile.length() } needsDownloading = ( desiredBitRate == 0 || duration == null || duration == 0 || fileLength == 0L ) if (needsDownloading) { // Attempt partial HTTP GET, appending to the file if it exists. val (inStream, partial) = musicService.getDownloadInputStream( song, partialFile.length(), desiredBitRate, save ) inputStream = inStream if (partial) { Timber.i( "Executed partial HTTP GET, skipping %d bytes", partialFile.length() ) } outputStream = FileOutputStream(partialFile, partial) 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) { throw Exception(String.format("Download of '%s' was cancelled", song)) } downloadAndSaveCoverArt() } if (isPlaying) { completeWhenDone = true } else { if (save) { Util.renameFile(partialFile, saveFile) Util.scanMedia(saveFile) } else { Util.renameFile(partialFile, completeFile) } } } catch (all: Exception) { Util.close(outputStream) Util.delete(completeFile) Util.delete(saveFile) if (!isCancelled) { isFailed = true if (retryCount > 0) { --retryCount } Timber.w(all, "Failed to download '%s'.", song) } } finally { Util.close(inputStream) Util.close(outputStream) wifiLock?.release() CacheCleaner().cleanSpace() downloader.checkDownloads() } } override fun toString(): String { return String.format("DownloadTask (%s)", song) } 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()) } } private fun updateModificationDate(file: File) { if (file.exists()) { val ok = file.setLastModified(System.currentTimeMillis()) if (!ok) { Timber.i( "Failed to set last-modified date on %s, trying alternate method", file ) try { // Try alternate method to update last modified date to current time // Found at https://code.google.com/p/android/issues/detail?id=18624 // According to the bug, this was fixed in Android 8.0 (API 26) val raf = RandomAccessFile(file, "rw") val length = raf.length() raf.setLength(length + 1) raf.setLength(length) raf.close() } catch (e: Exception) { Timber.w(e, "Failed to set last-modified date on %s", file) } } } } override fun compareTo(other: DownloadFile): Int { return priority.compareTo(other.priority) } companion object { const val MAX_RETRIES = 5 } }