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

423 lines
13 KiB
Kotlin

/*
* 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.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.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.FileUtil
import org.moire.ultrasonic.util.Settings
import org.moire.ultrasonic.util.Util
import timber.log.Timber
/**
* This class represents a single Song or Video that can be downloaded.
*/
class DownloadFile(
val song: MusicDirectory.Entry,
private val save: Boolean
) : KoinComponent, Identifiable {
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 = Settings.maxBitRate
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()
private val activeServerProvider: ActiveServerProvider by inject()
val progress: MutableLiveData<Int> = MutableLiveData(0)
val status: MutableLiveData<DownloadStatus> = MutableLiveData(DownloadStatus.IDLE)
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() {
val musicService = getMusicService()
override fun execute() {
var inputStream: InputStream? = null
var outputStream: FileOutputStream? = null
try {
if (saveFile.exists()) {
Timber.i("%s already exists. Skipping.", saveFile)
status.postValue(DownloadStatus.DONE)
return
}
if (completeFile.exists()) {
if (save) {
if (isPlaying) {
saveWhenDone = true
} else {
Util.renameFile(completeFile, saveFile)
}
} else {
Timber.i("%s already exists. Skipping.", completeFile)
}
status.postValue(DownloadStatus.DONE)
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
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) {
status.postValue(DownloadStatus.ABORTED)
throw Exception(String.format("Download of '%s' was cancelled", song))
}
if (song.artistId != null) {
cacheMetadata(song.artistId!!)
}
downloadAndSaveCoverArt()
status.postValue(DownloadStatus.DONE)
}
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 > 1) {
status.postValue(DownloadStatus.RETRYING)
--retryCount
} else if (retryCount == 1) {
status.postValue(DownloadStatus.FAILED)
--retryCount
}
Timber.w(all, "Failed to download '%s'.", song)
}
} finally {
Util.close(inputStream)
Util.close(outputStream)
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<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(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: 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
}