Allow resumption of partial tracks, use Coroutines

This commit is contained in:
birdbird 2022-10-20 20:07:45 +00:00
parent 3437965dd3
commit 5b38e2b38f
9 changed files with 334 additions and 335 deletions

View File

@ -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();
}
}

View File

@ -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()
}
}
}

View File

@ -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
)
}
}

View File

@ -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 isnt 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())
}

View File

@ -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
}
}

View File

@ -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) {

View File

@ -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
}
}

View File

@ -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? {

View File

@ -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")