ultrasonic-app-subsonic-and.../ultrasonic/src/main/kotlin/org/moire/ultrasonic/util/CacheCleaner.kt

272 lines
9.6 KiB
Kotlin

package org.moire.ultrasonic.util
import android.system.Os
import java.util.ArrayList
import java.util.HashSet
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import org.koin.java.KoinJavaComponent.inject
import org.moire.ultrasonic.data.ActiveServerProvider
import org.moire.ultrasonic.domain.Playlist
import org.moire.ultrasonic.service.Downloader
import org.moire.ultrasonic.util.FileUtil.getAlbumArtFile
import org.moire.ultrasonic.util.FileUtil.getPlaylistDirectory
import org.moire.ultrasonic.util.FileUtil.getPlaylistFile
import org.moire.ultrasonic.util.FileUtil.listFiles
import org.moire.ultrasonic.util.FileUtil.musicDirectory
import org.moire.ultrasonic.util.Settings.cacheSizeMB
import org.moire.ultrasonic.util.FileUtil.delete
import org.moire.ultrasonic.util.Util.formatBytes
import timber.log.Timber
/**
* Responsible for cleaning up files from the offline download cache on the filesystem.
*/
class CacheCleaner : CoroutineScope by CoroutineScope(Dispatchers.IO) {
private fun exceptionHandler(tag: String): CoroutineExceptionHandler {
return CoroutineExceptionHandler { _, exception ->
Timber.w(exception, "Exception in CacheCleaner.$tag")
}
}
// Cache cleaning shouldn't run concurrently, as it is started after every completed download
// TODO serializing and throttling these is an ideal task for Rx
fun clean() {
if (cleaning) return
synchronized(lock) {
if (cleaning) return
cleaning = true
launch(exceptionHandler("clean")) {
backgroundCleanup()
}
}
}
fun cleanSpace() {
if (spaceCleaning) return
synchronized(lock) {
if (spaceCleaning) return
spaceCleaning = true
launch(exceptionHandler("cleanSpace")) {
backgroundSpaceCleanup()
}
}
}
fun cleanPlaylists(playlists: List<Playlist>) {
if (playlistCleaning) return
synchronized(lock) {
if (playlistCleaning) return
playlistCleaning = true
launch(exceptionHandler("cleanPlaylists")) {
backgroundPlaylistsCleanup(playlists)
}
}
}
private fun backgroundCleanup() {
try {
val files: MutableList<StorageFile> = ArrayList()
val dirs: MutableList<StorageFile> = ArrayList()
findCandidatesForDeletion(musicDirectory, files, dirs)
sortByAscendingModificationTime(files)
val filesToNotDelete = findFilesToNotDelete()
deleteFiles(files, filesToNotDelete, getMinimumDelete(files), true)
deleteEmptyDirs(dirs, filesToNotDelete)
} catch (all: RuntimeException) {
Timber.e(all, "Error in cache cleaning.")
} finally {
cleaning = false
}
}
private fun backgroundSpaceCleanup() {
try {
val files: MutableList<StorageFile> = ArrayList()
val dirs: MutableList<StorageFile> = ArrayList()
findCandidatesForDeletion(musicDirectory, files, dirs)
val bytesToDelete = getMinimumDelete(files)
if (bytesToDelete > 0L) {
sortByAscendingModificationTime(files)
val filesToNotDelete = findFilesToNotDelete()
deleteFiles(files, filesToNotDelete, bytesToDelete, false)
}
} catch (all: RuntimeException) {
Timber.e(all, "Error in cache cleaning.")
} finally {
spaceCleaning = false
}
}
private fun backgroundPlaylistsCleanup(vararg params: List<Playlist>) {
try {
val activeServerProvider = inject<ActiveServerProvider>(
ActiveServerProvider::class.java
)
val server = activeServerProvider.value.getActiveServer().name
val playlistFiles = listFiles(getPlaylistDirectory(server))
val playlists = params[0]
for ((_, name) in playlists) {
playlistFiles.remove(getPlaylistFile(server, name))
}
for (playlist in playlistFiles) {
playlist.delete()
}
} catch (all: RuntimeException) {
Timber.e(all, "Error in playlist cache cleaning.")
} finally {
playlistCleaning = false
}
}
companion object {
private val lock = Object()
private var cleaning = false
private var spaceCleaning = false
private var playlistCleaning = false
private const val MIN_FREE_SPACE = 500 * 1024L * 1024L
private fun deleteEmptyDirs(dirs: Iterable<StorageFile>, doNotDelete: Collection<String>) {
for (dir in dirs) {
if (doNotDelete.contains(dir.path)) continue
var children = dir.listFiles()
if (children != null) {
// No songs left in the folder
if (children.size == 1 && children[0].path == getAlbumArtFile(dir.path)) {
// Delete Artwork files
delete(getAlbumArtFile(dir.path))
children = dir.listFiles()
}
// Delete empty directory
if (children != null && children.isEmpty()) {
delete(dir.path)
}
}
}
}
private fun getMinimumDelete(files: List<StorageFile>): Long {
if (files.isEmpty()) return 0L
val cacheSizeBytes = cacheSizeMB * 1024L * 1024L
var bytesUsedBySubsonic = 0L
for (file in files) {
bytesUsedBySubsonic += file.length
}
// Ensure that file system is not more than 95% full.
val bytesUsedFs: Long
val minFsAvailability: Long
val bytesTotalFs: Long
val bytesAvailableFs: Long
val descriptor = files[0].getDocumentFileDescriptor("r")!!
val stat = Os.fstatvfs(descriptor.fileDescriptor)
bytesTotalFs = stat.f_blocks * stat.f_bsize
bytesAvailableFs = stat.f_bfree * stat.f_bsize
bytesUsedFs = bytesTotalFs - bytesAvailableFs
minFsAvailability = bytesTotalFs - MIN_FREE_SPACE
descriptor.close()
val bytesToDeleteCacheLimit = (bytesUsedBySubsonic - cacheSizeBytes).coerceAtLeast(0L)
val bytesToDeleteFsLimit = (bytesUsedFs - minFsAvailability).coerceAtLeast(0L)
val bytesToDelete = bytesToDeleteCacheLimit.coerceAtLeast(bytesToDeleteFsLimit)
Timber.i(
"File system : %s of %s available",
formatBytes(bytesAvailableFs),
formatBytes(bytesTotalFs)
)
Timber.i("Cache limit : %s", formatBytes(cacheSizeBytes))
Timber.i("Cache size before : %s", formatBytes(bytesUsedBySubsonic))
Timber.i("Minimum to delete : %s", formatBytes(bytesToDelete))
return bytesToDelete
}
private fun isPartial(file: StorageFile): Boolean {
return file.name.endsWith(".partial") || file.name.contains(".partial.")
}
private fun isComplete(file: StorageFile): Boolean {
return file.name.endsWith(".complete") || file.name.contains(".complete.")
}
@Suppress("NestedBlockDepth")
private fun deleteFiles(
files: Collection<StorageFile>,
doNotDelete: Collection<String>,
bytesToDelete: Long,
deletePartials: Boolean
) {
if (files.isEmpty()) {
return
}
var bytesDeleted = 0L
for (file in files) {
if (!deletePartials && bytesDeleted > bytesToDelete) break
if (bytesToDelete > bytesDeleted || deletePartials && isPartial(file)) {
if (!doNotDelete.contains(file.path) && file.name != Constants.ALBUM_ART_FILE) {
val size = file.length
if (delete(file.path)) {
bytesDeleted += size
}
}
}
}
Timber.i("Deleted: %s", formatBytes(bytesDeleted))
}
private fun findCandidatesForDeletion(
file: StorageFile,
files: MutableList<StorageFile>,
dirs: MutableList<StorageFile>
) {
if (file.isFile && (isPartial(file) || isComplete(file))) {
files.add(file)
} else {
// Depth-first
for (child in listFiles(file)) {
findCandidatesForDeletion(child, files, dirs)
}
dirs.add(file)
}
}
private fun sortByAscendingModificationTime(files: MutableList<StorageFile>) {
files.sortWith { a: StorageFile, b: StorageFile ->
a.lastModified.compareTo(b.lastModified)
}
}
private fun findFilesToNotDelete(): Set<String> {
val filesToNotDelete: MutableSet<String> = HashSet(5)
val downloader = inject<Downloader>(
Downloader::class.java
)
for (downloadFile in downloader.value.all) {
filesToNotDelete.add(downloadFile.partialFile)
filesToNotDelete.add(downloadFile.completeOrSaveFile)
}
filesToNotDelete.add(musicDirectory.path)
return filesToNotDelete
}
}
}