From 5b38e2b38f93fe5159dd05a1ea4544571c42fdba Mon Sep 17 00:00:00 2001 From: birdbird <6892457-tzugen@users.noreply.gitlab.com> Date: Thu, 20 Oct 2022 20:07:45 +0000 Subject: [PATCH] Allow resumption of partial tracks, use Coroutines --- .../ultrasonic/util/CancellableTask.java | 100 ------ .../fragment/TrackCollectionFragment.kt | 64 ++-- .../ultrasonic/model/TrackCollectionModel.kt | 58 ++++ .../ultrasonic/service/DownloadService.kt | 65 ++-- .../moire/ultrasonic/service/DownloadTask.kt | 299 +++++++++--------- .../org/moire/ultrasonic/util/CacheCleaner.kt | 2 +- .../org/moire/ultrasonic/util/FileUtil.kt | 45 ++- .../org/moire/ultrasonic/util/Storage.kt | 30 +- .../org/moire/ultrasonic/util/StorageFile.kt | 6 +- 9 files changed, 334 insertions(+), 335 deletions(-) delete mode 100644 ultrasonic/src/main/java/org/moire/ultrasonic/util/CancellableTask.java diff --git a/ultrasonic/src/main/java/org/moire/ultrasonic/util/CancellableTask.java b/ultrasonic/src/main/java/org/moire/ultrasonic/util/CancellableTask.java deleted file mode 100644 index 6c70339d..00000000 --- a/ultrasonic/src/main/java/org/moire/ultrasonic/util/CancellableTask.java +++ /dev/null @@ -1,100 +0,0 @@ -/* - This file is part of Subsonic. - - Subsonic is free software: you can redistribute it and/or modify - it under the terms of the GNU General Public License as published by - the Free Software Foundation, either version 3 of the License, or - (at your option) any later version. - - Subsonic is distributed in the hope that it will be useful, - but WITHOUT ANY WARRANTY; without even the implied warranty of - MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - GNU General Public License for more details. - - You should have received a copy of the GNU General Public License - along with Subsonic. If not, see . - - Copyright 2009 (C) Sindre Mehus - */ -package org.moire.ultrasonic.util; - -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.concurrent.atomic.AtomicReference; - -import timber.log.Timber; - -/** - * @author Sindre Mehus - * @version $Id$ - */ -public abstract class CancellableTask -{ - private final AtomicBoolean running = new AtomicBoolean(false); - private final AtomicBoolean cancelled = new AtomicBoolean(false); - private final AtomicReference thread = new AtomicReference(); - private final AtomicReference cancelListener = new AtomicReference(); - - public void cancel() - { - Timber.i("Cancelling %s", CancellableTask.this); - cancelled.set(true); - - OnCancelListener listener = cancelListener.get(); - if (listener != null) - { - try - { - listener.onCancel(); - } - catch (Throwable x) - { - Timber.w(x, "Error when invoking OnCancelListener."); - } - } - } - - public boolean isCancelled() - { - return cancelled.get(); - } - - public void setOnCancelListener(OnCancelListener listener) - { - cancelListener.set(listener); - } - - public boolean isRunning() - { - return running.get(); - } - - public abstract void execute(); - - public void start() - { - thread.set(new Thread() - { - @Override - public void run() - { - running.set(true); - Timber.i("Starting thread for %s", CancellableTask.this); - try - { - execute(); - } - finally - { - running.set(false); - Timber.i("Stopping thread for %s", CancellableTask.this); - } - } - }); - thread.get().start(); - } - - public interface OnCancelListener - { - void onCancel(); - } -} \ No newline at end of file diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/TrackCollectionFragment.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/TrackCollectionFragment.kt index 2f16475c..433cec6a 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/TrackCollectionFragment.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/fragment/TrackCollectionFragment.kt @@ -15,6 +15,7 @@ import android.view.View import androidx.core.view.isVisible import androidx.fragment.app.viewModels import androidx.lifecycle.LiveData +import androidx.lifecycle.lifecycleScope import androidx.lifecycle.viewModelScope import androidx.navigation.fragment.findNavController import androidx.navigation.fragment.navArgs @@ -23,6 +24,7 @@ import androidx.recyclerview.widget.RecyclerView import com.google.android.material.button.MaterialButton import io.reactivex.rxjava3.disposables.CompositeDisposable import java.util.Collections +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import org.koin.android.ext.android.inject import org.moire.ultrasonic.R @@ -37,8 +39,6 @@ import org.moire.ultrasonic.domain.MusicDirectory import org.moire.ultrasonic.domain.Track import org.moire.ultrasonic.fragment.FragmentTitle.Companion.setTitle import org.moire.ultrasonic.model.TrackCollectionModel -import org.moire.ultrasonic.service.DownloadService -import org.moire.ultrasonic.service.DownloadState import org.moire.ultrasonic.service.MediaPlayerController import org.moire.ultrasonic.service.RxBus import org.moire.ultrasonic.service.plusAssign @@ -150,16 +150,16 @@ open class TrackCollectionFragment : MultiListFragment() { if (it.progress != null) return@subscribe val selectedSongs = getSelectedSongs() if (!selectedSongs.any { song -> song.id == it.id }) return@subscribe - enableButtons(selectedSongs) + triggerButtonUpdate(selectedSongs) } - enableButtons() + triggerButtonUpdate() // Update the buttons when the selection has changed viewAdapter.selectionRevision.observe( viewLifecycleOwner ) { - enableButtons() + triggerButtonUpdate() } } @@ -367,41 +367,27 @@ open class TrackCollectionFragment : MultiListFragment() { } } - @Suppress("ComplexMethod") - internal open fun enableButtons(selection: List = getSelectedSongs()) { - val enabled = selection.isNotEmpty() - var unpinEnabled = false - var deleteEnabled = false - var downloadEnabled = false - val multipleSelection = viewAdapter.hasMultipleSelection() + @Synchronized + fun triggerButtonUpdate(selection: List = getSelectedSongs()) { + listModel.calculateButtonState(selection, ::updateButtonState) + } - var pinnedCount = 0 + private fun updateButtonState( + show: TrackCollectionModel.Companion.ButtonStates, + ) { + // We are coming back from unknown context + // and need to ensure Main Thread in order to manipulate the UI + viewLifecycleOwner.lifecycleScope.launch(Dispatchers.Main) { + val multipleSelection = viewAdapter.hasMultipleSelection() - for (song in selection) { - val state = DownloadService.getDownloadState(song) - when (state) { - DownloadState.DONE -> { - deleteEnabled = true - } - DownloadState.PINNED -> { - deleteEnabled = true - pinnedCount++ - unpinEnabled = true - } - DownloadState.IDLE, DownloadState.FAILED -> { - downloadEnabled = true - } - else -> {} - } + playNowButton?.isVisible = show.all + playNextButton?.isVisible = show.all && multipleSelection + playLastButton?.isVisible = show.all && multipleSelection + pinButton?.isVisible = show.all && !isOffline() && show.pin + unpinButton?.isVisible = show.all && show.unpin + downloadButton?.isVisible = show.all && show.download && !isOffline() + deleteButton?.isVisible = show.all && show.delete } - - playNowButton?.isVisible = enabled - playNextButton?.isVisible = enabled && multipleSelection - playLastButton?.isVisible = enabled && multipleSelection - pinButton?.isVisible = enabled && !isOffline() && selection.size > pinnedCount - unpinButton?.isVisible = enabled && unpinEnabled - downloadButton?.isVisible = enabled && downloadEnabled && !isOffline() - deleteButton?.isVisible = enabled && deleteEnabled } private fun downloadBackground(save: Boolean) { @@ -504,7 +490,7 @@ open class TrackCollectionFragment : MultiListFragment() { // Show a text if we have no entries emptyView.isVisible = entryList.isEmpty() - enableButtons() + triggerButtonUpdate() val isAlbumList = (navArgs.albumListType != null) @@ -729,7 +715,7 @@ open class TrackCollectionFragment : MultiListFragment() { VideoPlayer.playVideo(requireContext(), item) } else -> { - enableButtons() + triggerButtonUpdate() } } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/model/TrackCollectionModel.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/model/TrackCollectionModel.kt index ea4ce695..eaf40924 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/model/TrackCollectionModel.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/model/TrackCollectionModel.kt @@ -9,9 +9,14 @@ package org.moire.ultrasonic.model import android.app.Application import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.viewModelScope import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.moire.ultrasonic.domain.MusicDirectory +import org.moire.ultrasonic.domain.Track +import org.moire.ultrasonic.service.DownloadService +import org.moire.ultrasonic.service.DownloadState import org.moire.ultrasonic.service.MusicServiceFactory import org.moire.ultrasonic.util.Settings import org.moire.ultrasonic.util.Util @@ -156,4 +161,57 @@ class TrackCollectionModel(application: Application) : GenericListModel(applicat private fun updateList(root: MusicDirectory) { currentList.postValue(root.getChildren()) } + + @Synchronized + fun calculateButtonState( + selection: List, + onComplete: (TrackCollectionModel.Companion.ButtonStates) -> Unit + ) { + val enabled = selection.isNotEmpty() + var unpinEnabled = false + var deleteEnabled = false + var downloadEnabled = false + var pinnedCount = 0 + + viewModelScope.launch(Dispatchers.IO) { + for (song in selection) { + when (DownloadService.getDownloadState(song)) { + DownloadState.DONE -> { + deleteEnabled = true + } + DownloadState.PINNED -> { + deleteEnabled = true + pinnedCount++ + unpinEnabled = true + } + DownloadState.IDLE, DownloadState.FAILED -> { + downloadEnabled = true + } + else -> {} + } + } + }.invokeOnCompletion { + val pinEnabled = selection.size > pinnedCount + + onComplete( + TrackCollectionModel.Companion.ButtonStates( + all = enabled, + pin = pinEnabled, + unpin = unpinEnabled, + delete = deleteEnabled, + download = downloadEnabled + ) + ) + } + } + + companion object { + data class ButtonStates( + val all: Boolean, + val pin: Boolean, + val unpin: Boolean, + val delete: Boolean, + val download: Boolean + ) + } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadService.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadService.kt index 4c34c1fe..d714720f 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadService.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadService.kt @@ -24,6 +24,10 @@ import com.google.common.util.concurrent.ListenableFuture import com.google.common.util.concurrent.MoreExecutors import com.google.common.util.concurrent.SettableFuture import java.util.PriorityQueue +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel import org.koin.core.component.KoinComponent import org.koin.core.component.inject import org.moire.ultrasonic.R @@ -54,9 +58,9 @@ private const val CHECK_INTERVAL = 5000L * "A foreground service is a service that the user is * actively aware of and isn’t a candidate for the system to kill when low on memory." * - * TODO: Migrate this to use the Media3 DownloadHelper */ class DownloadService : Service(), KoinComponent { + private var scope: CoroutineScope? = null private val storageMonitor: ExternalStorageMonitor by inject() private val binder: IBinder = SimpleServiceBinder(this) @@ -72,6 +76,11 @@ class DownloadService : Service(), KoinComponent { override fun onCreate() { super.onCreate() + // Create Coroutine lifecycle scope. We use a SupervisorJob(), otherwise the failure of one + // would mean the failure of all jobs! + val supervisor = SupervisorJob() + scope = CoroutineScope(Dispatchers.IO + supervisor) + // Create Notification Channel createNotificationChannel() updateNotification() @@ -104,16 +113,14 @@ class DownloadService : Service(), KoinComponent { clearDownloads() observableDownloads.value = listOf() + scope?.cancel() + scope = null + Timber.i("DownloadService destroyed") } - fun addTracks(tracks: List) { - downloadQueue.addAll(tracks) - tracks.forEach { postState(it.track, DownloadState.QUEUED) } - processNextTracks() - } - - private fun processNextTracks() { + @Synchronized + fun processNextTracks() { retrying = false if ( !Util.isNetworkConnected() || @@ -129,19 +136,17 @@ class DownloadService : Service(), KoinComponent { // Fill up active List with waiting tasks while (activelyDownloading.size < Settings.parallelDownloads && downloadQueue.size > 0) { - val task = downloadQueue.remove() - val downloadTask = DownloadTask(task) { downloadableTrack, downloadState, progress -> - downloadStateChangedCallback(downloadableTrack, downloadState, progress) - } - activelyDownloading[task] = downloadTask - FileUtil.createDirectoryForParent(task.pinnedFile) - activelyDownloading[task]?.start() - + val track = downloadQueue.remove() + val downloadTask = DownloadTask(track, scope!!, ::downloadStateChangedCallback) + activelyDownloading[track] = downloadTask + FileUtil.createDirectoryForParent(track.pinnedFile) + downloadTask.start() listChanged = true } // Stop Executor service when done downloading if (activelyDownloading.isEmpty()) { + CacheCleaner().cleanSpace() stopSelf() } @@ -151,6 +156,7 @@ class DownloadService : Service(), KoinComponent { } private fun retryProcessNextTracks() { + Timber.i("Scheduling retry to process next tracks") if (isShuttingDown || retrying) return retrying = true Handler(Looper.getMainLooper()).postDelayed( @@ -282,6 +288,7 @@ class DownloadService : Service(), KoinComponent { private var backgroundPriorityCounter = 100 + @Synchronized fun download( tracks: List, save: Boolean, @@ -292,12 +299,7 @@ class DownloadService : Service(), KoinComponent { if (save) { tracks.filter { Storage.isPathExists(it.getCompleteFile()) }.forEach { track -> Storage.getFromPath(track.getCompleteFile())?.let { - try { - Storage.rename(it, track.getPinnedFile()) - } catch (ignored: FileAlreadyExistsException) { - // Play console has revealed a crash when for some reason both files exist - Storage.delete(it.path) - } + Storage.renameOrDeleteIfAlreadyExists(it, track.getPinnedFile()) postState(track, DownloadState.PINNED) } } @@ -305,12 +307,7 @@ class DownloadService : Service(), KoinComponent { } else { tracks.filter { Storage.isPathExists(it.getPinnedFile()) }.forEach { track -> Storage.getFromPath(track.getPinnedFile())?.let { - try { - Storage.rename(it, track.getCompleteFile()) - } catch (ignored: FileAlreadyExistsException) { - // Play console has revealed a crash when for some reason both files exist - Storage.delete(it.path) - } + Storage.renameOrDeleteIfAlreadyExists(it, track.getCompleteFile()) postState(track, DownloadState.DONE) } } @@ -346,7 +343,11 @@ class DownloadService : Service(), KoinComponent { ) } - if (tracksToDownload.isNotEmpty()) addTracks(tracksToDownload) + if (tracksToDownload.isNotEmpty()) { + downloadQueue.addAll(tracksToDownload) + tracksToDownload.forEach { postState(it.track, DownloadState.QUEUED) } + processNextTracksOnService() + } } fun requestStop() { @@ -404,12 +405,12 @@ class DownloadService : Service(), KoinComponent { return DownloadState.IDLE } - private fun addTracks(tracks: List) { + private fun processNextTracksOnService() { val serviceFuture = startFuture ?: requestStart() serviceFuture.addListener({ val service = serviceFuture.get() - service.addTracks(tracks) - Timber.i("Added tracks to DownloadService") + service.processNextTracks() + Timber.i("DownloadService processNextTracks executed.") }, MoreExecutors.directExecutor()) } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadTask.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadTask.kt index 7d52c23a..f978a337 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadTask.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/service/DownloadTask.kt @@ -8,10 +8,14 @@ package org.moire.ultrasonic.service import android.os.SystemClock -import java.io.IOException import java.io.InputStream import java.io.OutputStream -import java.util.Locale +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineExceptionHandler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Job +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch import org.koin.core.component.KoinComponent import org.koin.core.component.inject import org.moire.ultrasonic.data.ActiveServerProvider @@ -20,9 +24,8 @@ import org.moire.ultrasonic.domain.Album import org.moire.ultrasonic.domain.Artist import org.moire.ultrasonic.domain.Track 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.FileUtil.copyWithProgress import org.moire.ultrasonic.util.Settings import org.moire.ultrasonic.util.Storage import org.moire.ultrasonic.util.Util @@ -34,150 +37,175 @@ private const val REFRESH_INTERVAL = 50 class DownloadTask( private val item: DownloadableTrack, + private val scope: CoroutineScope, private val stateChangedCallback: (DownloadableTrack, DownloadState, progress: Int?) -> Unit -) : - CancellableTask(), KoinComponent { - val musicService = MusicServiceFactory.getMusicService() - +) : KoinComponent { + private val musicService = MusicServiceFactory.getMusicService() private val imageLoaderProvider: ImageLoaderProvider by inject() private val activeServerProvider: ActiveServerProvider by inject() - @Suppress("LongMethod", "ComplexMethod", "NestedBlockDepth", "TooGenericExceptionThrown") - override fun execute() { + private var job: Job? = null + private var inputStream: InputStream? = null + private var outputStream: OutputStream? = null + private var lastPostTime: Long = 0 - var inputStream: InputStream? = null - var outputStream: OutputStream? = null - try { - if (Storage.isPathExists(item.pinnedFile)) { - Timber.i("%s already exists. Skipping.", item.pinnedFile) - stateChangedCallback(item, DownloadState.PINNED, null) - return - } + private fun checkIfExists(): Boolean { + if (Storage.isPathExists(item.pinnedFile)) { + Timber.i("%s already exists. Skipping.", item.pinnedFile) + stateChangedCallback(item, DownloadState.PINNED, null) + return true + } - if (Storage.isPathExists(item.completeFile)) { - var newStatus: DownloadState = DownloadState.DONE - if (item.pinned) { - Storage.rename( - item.completeFile, - item.pinnedFile - ) - newStatus = DownloadState.PINNED - } else { - Timber.i( - "%s already exists. Skipping.", - item.completeFile - ) - } - - // Hidden feature: If track is toggled between pinned/saved, refresh the metadata.. - try { - item.track.cacheMetadataAndArtwork() - } catch (ignore: Exception) { - Timber.w(ignore) - } - stateChangedCallback(item, newStatus, null) - return - } - - stateChangedCallback(item, DownloadState.DOWNLOADING, null) - - val fileLength = Storage.getFromPath(item.partialFile)?.length ?: 0 - - // Attempt partial HTTP GET, appending to the file if it exists. - val (inStream, isPartial) = musicService.getDownloadInputStream( - item.track, fileLength, - Settings.maxBitRate, - item.pinned - ) - - inputStream = inStream - - if (isPartial) { - Timber.i("Executed partial HTTP GET, skipping %d bytes", fileLength) - } - - outputStream = Storage.getOrCreateFileFromPath(item.partialFile) - .getFileOutputStream(isPartial) - - var lastPostTime: Long = 0 - val len = inputStream.copyTo(outputStream) { totalBytesCopied -> - // Manual throttling to avoid overloading Rx - if (SystemClock.elapsedRealtime() - lastPostTime > REFRESH_INTERVAL) { - lastPostTime = SystemClock.elapsedRealtime() - - // If the file size is unknown we can only provide null as the progress - val size = item.track.size ?: 0 - val progress = if (size <= 0) { - null - } else { - (totalBytesCopied * 100 / (size)).toInt() - } - - stateChangedCallback( - item, - DownloadState.DOWNLOADING, - progress - ) - } - } - - Timber.i("Downloaded %d bytes to %s", len, item.partialFile) - - inputStream.close() - outputStream.flush() - outputStream.close() - - if (isCancelled) { - stateChangedCallback(item, DownloadState.CANCELLED, null) - throw RuntimeException( - String.format( - Locale.ROOT, "Download of '%s' was cancelled", - item - ) + if (Storage.isPathExists(item.completeFile)) { + var newStatus: DownloadState = DownloadState.DONE + if (item.pinned) { + Storage.rename( + item.completeFile, + item.pinnedFile + ) + newStatus = DownloadState.PINNED + } else { + Timber.i( + "%s already exists. Skipping.", + item.completeFile ) } + // Hidden feature: If track is toggled between pinned/saved, refresh the metadata.. try { item.track.cacheMetadataAndArtwork() } catch (ignore: Exception) { Timber.w(ignore) } + stateChangedCallback(item, newStatus, null) + return true + } - if (item.pinned) { - Storage.rename( - item.partialFile, - item.pinnedFile - ) - stateChangedCallback(item, DownloadState.PINNED, null) - Util.scanMedia(item.pinnedFile) + return false + } + + fun download() { + stateChangedCallback(item, DownloadState.DOWNLOADING, null) + + val fileLength = Storage.getFromPath(item.partialFile)?.length ?: 0 + + // Attempt partial HTTP GET, appending to the file if it exists. + val (inStream, isPartial) = musicService.getDownloadInputStream( + item.track, fileLength, + Settings.maxBitRate, + item.pinned + ) + + inputStream = inStream + + if (isPartial) { + Timber.i("Executed partial HTTP GET, skipping %d bytes", fileLength) + } + + outputStream = Storage.getOrCreateFileFromPath(item.partialFile) + .getFileOutputStream(isPartial) + + val len = inputStream!!.copyWithProgress(outputStream!!) { totalBytesCopied -> + // Add previous existing file length for correct display when resuming + publishProgressUpdate(fileLength + totalBytesCopied) + } + + Timber.i("Downloaded %d bytes to %s", len, item.partialFile) + + inputStream?.close() + outputStream?.flush() + outputStream?.close() + } + + private fun publishProgressUpdate(totalBytesCopied: Long) { + // Check if we are cancelled... + if (job?.isCancelled == true) { + throw CancellationException() + } + + // Manual throttling to avoid overloading Rx + if (SystemClock.elapsedRealtime() - lastPostTime > REFRESH_INTERVAL) { + lastPostTime = SystemClock.elapsedRealtime() + + // If the file size is unknown we can only provide null as the progress + val size = item.track.size ?: 0 + val progress = if (size <= 0) { + null } else { - Storage.rename( - item.partialFile, - item.completeFile - ) - stateChangedCallback(item, DownloadState.DONE, null) + (totalBytesCopied * 100 / (size)).toInt() } - } catch (all: Exception) { - outputStream.safeClose() - Storage.delete(item.completeFile) - Storage.delete(item.pinnedFile) - if (!isCancelled) { - if (item.tryCount < MAX_RETRIES) { - stateChangedCallback(item, DownloadState.RETRYING, null) - } else { - stateChangedCallback(item, DownloadState.FAILED, null) - } - Timber.w(all, "Failed to download '%s'.", item) - } - } finally { - inputStream.safeClose() - outputStream.safeClose() - CacheCleaner().cleanSpace() + + stateChangedCallback( + item, + DownloadState.DOWNLOADING, + progress + ) } } - override fun toString(): String { - return String.format(Locale.ROOT, "DownloadTask (%s)", item) + private fun afterDownload() { + try { + item.track.cacheMetadataAndArtwork() + } catch (ignore: Exception) { + Timber.w(ignore) + } + + if (item.pinned) { + Storage.rename( + item.partialFile, + item.pinnedFile + ) + Timber.i("Renamed file to ${item.pinnedFile}") + stateChangedCallback(item, DownloadState.PINNED, null) + Util.scanMedia(item.pinnedFile) + } else { + Storage.rename( + item.partialFile, + item.completeFile + ) + Timber.i("Renamed file to ${item.completeFile}") + stateChangedCallback(item, DownloadState.DONE, null) + } + } + + private fun onCompletion(e: Throwable?) { + if (e is CancellationException) { + Timber.w(e, "CompletionHandler ${item.pinnedFile}") + stateChangedCallback(item, DownloadState.CANCELLED, null) + } else if (e != null) { + Timber.w(e, "CompletionHandler ${item.pinnedFile}") + if (item.tryCount < MAX_RETRIES) { + stateChangedCallback(item, DownloadState.RETRYING, null) + } else { + stateChangedCallback(item, DownloadState.FAILED, null) + } + } + inputStream.safeClose() + outputStream.safeClose() + } + + private fun exceptionHandler(): CoroutineExceptionHandler { + return CoroutineExceptionHandler { _, exception -> + Timber.w(exception, "Exception in DownloadTask ${item.pinnedFile}") + Storage.delete(item.completeFile) + Storage.delete(item.pinnedFile) + } + } + + fun start() { + Timber.i("Launching new Job ${item.pinnedFile}") + job = scope.launch(exceptionHandler()) { + if (!checkIfExists() && isActive) { + download() + afterDownload() + } + } + + job!!.invokeOnCompletion(::onCompletion) + } + + fun cancel() { + job?.cancel() } private fun Track.cacheMetadataAndArtwork() { @@ -230,7 +258,6 @@ class DownloadTask( imageLoader.cacheCoverArt(this) // Cache small copies of the Artist picture - directArtist?.let { imageLoader.cacheArtistPicture(it) } compilationArtist?.let { imageLoader.cacheArtistPicture(it) } } @@ -258,18 +285,4 @@ class DownloadTask( return artist } - - @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 - } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/CacheCleaner.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/CacheCleaner.kt index c5325ad5..675979f7 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/CacheCleaner.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/CacheCleaner.kt @@ -130,7 +130,7 @@ class CacheCleaner : CoroutineScope by CoroutineScope(Dispatchers.IO), KoinCompo offlineDB.artistDao().delete(it) } - Timber.e("Database cleanup done") + Timber.i("Database cleanup done") } fun cleanDatabaseSelective(trackToRemove: Track) { diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/FileUtil.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/FileUtil.kt index c3f308bd..9b16fbe2 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/FileUtil.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/FileUtil.kt @@ -18,8 +18,10 @@ import java.io.FileInputStream import java.io.FileOutputStream import java.io.FileWriter import java.io.IOException +import java.io.InputStream import java.io.ObjectInputStream import java.io.ObjectOutputStream +import java.io.OutputStream import java.io.Serializable import java.util.Locale import java.util.SortedSet @@ -50,32 +52,32 @@ object FileUtil { const val SUFFIX_SMALL = ".jpeg-small" private const val UNNAMED = "unnamed" - fun getSongFile(song: Track): String { - val dir = getAlbumDirectory(song) + fun getSongFile(track: Track): String { + val dir = getAlbumDirectory(track) // Do not generate new name for offline files. Offline files will have their Path as their Id. - if (!TextUtils.isEmpty(song.id)) { - if (song.id.startsWith(dir)) return song.id + if (!TextUtils.isEmpty(track.id)) { + if (track.id.startsWith(dir)) return track.id } // Generate a file name for the song val fileName = StringBuilder(256) - val track = song.track + val trackNumber = track.track // check if filename already had track number - if (song.title != null && !TITLE_WITH_TRACK.matcher(song.title!!).matches()) { - if (track != null) { - if (track < 10) { + if (track.title != null && !TITLE_WITH_TRACK.matcher(track.title!!).matches()) { + if (trackNumber != null) { + if (trackNumber < 10) { fileName.append('0') } - fileName.append(track).append('-') + fileName.append(trackNumber).append('-') } } - fileName.append(fileSystemSafe(song.title)).append('.') - if (!TextUtils.isEmpty(song.transcodedSuffix)) { - fileName.append(song.transcodedSuffix) + fileName.append(fileSystemSafe(track.title)).append('.') + if (!TextUtils.isEmpty(track.transcodedSuffix)) { + fileName.append(track.transcodedSuffix) } else { - fileName.append(song.suffix) + fileName.append(track.suffix) } return "$dir/$fileName" } @@ -508,4 +510,21 @@ object FileUtil { fw.safeClose() } } + + @Throws(IOException::class) + fun InputStream.copyWithProgress( + out: OutputStream, + onCopy: (totalBytesCopied: Long) -> Any + ): Long { + var bytesCopied: Long = 0 + val buffer = ByteArray(DEFAULT_BUFFER_SIZE) + var bytes = read(buffer) + while (bytes >= 0) { + out.write(buffer, 0, bytes) + bytesCopied += bytes + onCopy(bytesCopied) + bytes = read(buffer) + } + return bytesCopied + } } diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Storage.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Storage.kt index a1cf3602..6a5c1ff0 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Storage.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/Storage.kt @@ -64,13 +64,35 @@ object Storage { mediaRoot.value.rename(pathFrom, pathTo) } + fun renameOrDeleteIfAlreadyExists(pathFrom: AbstractFile, pathTo: String) { + try { + rename(pathFrom, pathTo) + } catch (ignored: FileAlreadyExistsException) { + // Play console has revealed a crash when for some reason both files exist + delete(pathFrom.path) + } + } + fun delete(path: String): Boolean { - val storageFile = getFromPath(path) - if (storageFile != null && !storageFile.delete()) { - Timber.w("Failed to delete file %s", path) + // Some implementations will return false on Error, + // others will throw a FileNotFoundException... + // Handle both here.. + + val success: Boolean + + try { + val storageFile = getFromPath(path) + success = storageFile?.delete() == true + } catch (all: Exception) { + Timber.d(all, "Failed to delete file $path") return false } - return true + + if (!success) { + Timber.d("Failed to delete file %s", path) + } + + return success } private fun getRoot(): AbstractFile? { diff --git a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/StorageFile.kt b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/StorageFile.kt index aa598d7e..9641f98f 100644 --- a/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/StorageFile.kt +++ b/ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/StorageFile.kt @@ -41,7 +41,7 @@ class StorageFile( } } } catch (_: IllegalArgumentException) { - Timber.d("Tried to get length of $uri but it probably doesn't exists") + Timber.i("Tried to get length of $uri but it probably doesn't exists") } return 0 } @@ -57,7 +57,7 @@ class StorageFile( } } } catch (_: IllegalArgumentException) { - Timber.d("Tried to get length of $uri but it probably doesn't exists") + Timber.i("Tried to get length of $uri but it probably doesn't exists") } return 0 } @@ -181,7 +181,7 @@ class StorageFile( override fun rename(pathFrom: AbstractFile, pathTo: String) { val fileFrom = pathFrom as StorageFile if (!fileFrom.exists()) throw IOException("File to rename doesn't exist") - Timber.d("Renaming from %s to %s", fileFrom.path, pathTo) + Timber.i("Renaming from %s to %s", fileFrom.path, pathTo) val parentTo = getFromPath(FileUtil.getParentPath(pathTo)!!) ?: throw IOException("Destination folder doesn't exist")