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

272 lines
9.6 KiB
Kotlin
Raw Normal View History

2021-11-01 17:07:18 +01:00
package org.moire.ultrasonic.util
import android.system.Os
2021-11-01 17:07:18 +01:00
import java.util.ArrayList
import java.util.HashSet
2021-11-13 12:06:16 +01:00
import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
2021-11-01 17:07:18 +01:00
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
2021-11-19 19:09:27 +01:00
import org.moire.ultrasonic.util.FileUtil.delete
2021-11-01 17:07:18 +01:00
import org.moire.ultrasonic.util.Util.formatBytes
import timber.log.Timber
/**
* Responsible for cleaning up files from the offline download cache on the filesystem.
*/
2021-11-13 12:06:16 +01:00
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
2021-11-01 17:07:18 +01:00
fun clean() {
if (cleaning) return
synchronized(lock) {
if (cleaning) return
cleaning = true
launch(exceptionHandler("clean")) {
backgroundCleanup()
}
2021-11-01 17:07:18 +01:00
}
}
fun cleanSpace() {
if (spaceCleaning) return
synchronized(lock) {
if (spaceCleaning) return
spaceCleaning = true
launch(exceptionHandler("cleanSpace")) {
backgroundSpaceCleanup()
}
2021-11-01 17:07:18 +01:00
}
}
fun cleanPlaylists(playlists: List<Playlist>) {
if (playlistCleaning) return
synchronized(lock) {
if (playlistCleaning) return
playlistCleaning = true
launch(exceptionHandler("cleanPlaylists")) {
backgroundPlaylistsCleanup(playlists)
}
2021-11-01 14:22:30 +01:00
}
}
2021-11-13 12:06:16 +01:00
private fun backgroundCleanup() {
2021-11-01 17:07:18 +01:00
try {
2021-11-19 20:34:03 +01:00
val files: MutableList<StorageFile> = ArrayList()
val dirs: MutableList<StorageFile> = ArrayList()
2021-11-13 12:06:16 +01:00
findCandidatesForDeletion(musicDirectory, files, dirs)
sortByAscendingModificationTime(files)
val filesToNotDelete = findFilesToNotDelete()
2021-11-13 12:06:16 +01:00
deleteFiles(files, filesToNotDelete, getMinimumDelete(files), true)
deleteEmptyDirs(dirs, filesToNotDelete)
} catch (all: RuntimeException) {
Timber.e(all, "Error in cache cleaning.")
} finally {
cleaning = false
2021-11-01 17:07:18 +01:00
}
}
2021-11-13 12:06:16 +01:00
private fun backgroundSpaceCleanup() {
try {
2021-11-19 20:34:03 +01:00
val files: MutableList<StorageFile> = ArrayList()
val dirs: MutableList<StorageFile> = ArrayList()
2021-11-13 12:06:16 +01:00
findCandidatesForDeletion(musicDirectory, files, dirs)
2021-11-19 19:09:27 +01:00
2021-11-13 12:06:16 +01:00
val bytesToDelete = getMinimumDelete(files)
if (bytesToDelete > 0L) {
2021-11-01 17:07:18 +01:00
sortByAscendingModificationTime(files)
val filesToNotDelete = findFilesToNotDelete()
deleteFiles(files, filesToNotDelete, bytesToDelete, false)
2021-11-01 17:07:18 +01:00
}
2021-11-13 12:06:16 +01:00
} catch (all: RuntimeException) {
Timber.e(all, "Error in cache cleaning.")
} finally {
spaceCleaning = false
2021-11-01 17:07:18 +01:00
}
}
2021-11-13 12:06:16 +01:00
private fun backgroundPlaylistsCleanup(vararg params: List<Playlist>) {
try {
val activeServerProvider = inject<ActiveServerProvider>(
ActiveServerProvider::class.java
)
2021-11-13 12:06:16 +01:00
val server = activeServerProvider.value.getActiveServer().name
val playlistFiles = listFiles(getPlaylistDirectory(server))
val playlists = params[0]
2021-11-13 12:06:16 +01:00
for ((_, name) in playlists) {
playlistFiles.remove(getPlaylistFile(server, name))
2021-11-01 17:07:18 +01:00
}
2021-11-13 12:06:16 +01:00
for (playlist in playlistFiles) {
playlist.delete()
2021-11-01 17:07:18 +01:00
}
2021-11-13 12:06:16 +01:00
} catch (all: RuntimeException) {
Timber.e(all, "Error in playlist cache cleaning.")
} finally {
playlistCleaning = false
2021-11-01 17:07:18 +01:00
}
}
companion object {
private val lock = Object()
private var cleaning = false
private var spaceCleaning = false
private var playlistCleaning = false
2021-11-01 17:07:18 +01:00
private const val MIN_FREE_SPACE = 500 * 1024L * 1024L
private fun deleteEmptyDirs(dirs: Iterable<StorageFile>, doNotDelete: Collection<String>) {
2021-11-01 17:07:18 +01:00
for (dir in dirs) {
if (doNotDelete.contains(dir.path)) continue
2021-11-01 17:07:18 +01:00
var children = dir.listFiles()
if (children != null) {
// No songs left in the folder
if (children.size == 1 && children[0].path == getAlbumArtFile(dir.path)) {
2021-11-01 17:07:18 +01:00
// Delete Artwork files
delete(getAlbumArtFile(dir.path))
2021-11-01 17:07:18 +01:00
children = dir.listFiles()
}
// Delete empty directory
if (children != null && children.isEmpty()) {
delete(dir.path)
2021-11-01 17:07:18 +01:00
}
}
}
}
private fun getMinimumDelete(files: List<StorageFile>): Long {
if (files.isEmpty()) return 0L
2021-11-01 17:07:18 +01:00
val cacheSizeBytes = cacheSizeMB * 1024L * 1024L
var bytesUsedBySubsonic = 0L
2021-11-01 17:07:18 +01:00
for (file in files) {
bytesUsedBySubsonic += file.length
2021-11-01 17:07:18 +01:00
}
// Ensure that file system is not more than 95% full.
val bytesUsedFs: Long
val minFsAvailability: Long
val bytesTotalFs: Long
val bytesAvailableFs: Long
2021-11-19 19:09:27 +01:00
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()
2021-11-19 19:09:27 +01:00
2021-11-01 17:07:18 +01:00
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))
2021-11-01 17:07:18 +01:00
return bytesToDelete
}
private fun isPartial(file: StorageFile): Boolean {
2021-11-01 17:07:18 +01:00
return file.name.endsWith(".partial") || file.name.contains(".partial.")
}
private fun isComplete(file: StorageFile): Boolean {
2021-11-01 17:07:18 +01:00
return file.name.endsWith(".complete") || file.name.contains(".complete.")
}
@Suppress("NestedBlockDepth")
private fun deleteFiles(
files: Collection<StorageFile>,
doNotDelete: Collection<String>,
2021-11-01 17:07:18 +01:00
bytesToDelete: Long,
deletePartials: Boolean
) {
if (files.isEmpty()) {
return
}
var bytesDeleted = 0L
2021-11-01 17:07:18 +01:00
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)) {
2021-11-01 17:07:18 +01:00
bytesDeleted += size
}
}
}
}
Timber.i("Deleted: %s", formatBytes(bytesDeleted))
}
private fun findCandidatesForDeletion(
file: StorageFile,
files: MutableList<StorageFile>,
dirs: MutableList<StorageFile>
2021-11-01 17:07:18 +01:00
) {
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)
2021-11-01 17:07:18 +01:00
}
}
private fun findFilesToNotDelete(): Set<String> {
val filesToNotDelete: MutableSet<String> = HashSet(5)
2021-11-01 17:07:18 +01:00
val downloader = inject<Downloader>(
Downloader::class.java
)
2021-11-01 17:07:18 +01:00
for (downloadFile in downloader.value.all) {
filesToNotDelete.add(downloadFile.partialFile)
filesToNotDelete.add(downloadFile.completeOrSaveFile)
}
filesToNotDelete.add(musicDirectory.path)
2021-11-01 17:07:18 +01:00
return filesToNotDelete
}
}
}