mirror of
https://gitlab.com/ultrasonic/ultrasonic.git
synced 2025-04-15 17:00:36 +03:00
Merge branch 'fixDownloads' into 'develop'
Allow resumption of partial tracks, use Coroutines See merge request ultrasonic/ultrasonic!852
This commit is contained in:
commit
788b80ee8b
@ -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 <http://www.gnu.org/licenses/>.
|
||||
|
||||
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> thread = new AtomicReference<Thread>();
|
||||
private final AtomicReference<OnCancelListener> cancelListener = new AtomicReference<OnCancelListener>();
|
||||
|
||||
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();
|
||||
}
|
||||
}
|
@ -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<MusicDirectory.Child>() {
|
||||
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<MusicDirectory.Child>() {
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("ComplexMethod")
|
||||
internal open fun enableButtons(selection: List<Track> = getSelectedSongs()) {
|
||||
val enabled = selection.isNotEmpty()
|
||||
var unpinEnabled = false
|
||||
var deleteEnabled = false
|
||||
var downloadEnabled = false
|
||||
val multipleSelection = viewAdapter.hasMultipleSelection()
|
||||
@Synchronized
|
||||
fun triggerButtonUpdate(selection: List<Track> = 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<MusicDirectory.Child>() {
|
||||
// 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<MusicDirectory.Child>() {
|
||||
VideoPlayer.playVideo(requireContext(), item)
|
||||
}
|
||||
else -> {
|
||||
enableButtons()
|
||||
triggerButtonUpdate()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
@ -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<Track>,
|
||||
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
|
||||
)
|
||||
}
|
||||
}
|
||||
|
@ -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<DownloadableTrack>) {
|
||||
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<Track>,
|
||||
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<DownloadableTrack>) {
|
||||
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())
|
||||
}
|
||||
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -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) {
|
||||
|
@ -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
|
||||
}
|
||||
}
|
||||
|
@ -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? {
|
||||
|
@ -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")
|
||||
|
Loading…
x
Reference in New Issue
Block a user