package com.github.libretube.api import com.github.libretube.LibreTubeApp import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext import kotlinx.serialization.decodeFromString import kotlinx.serialization.encodeToString import java.io.File import java.security.MessageDigest import java.util.concurrent.ConcurrentHashMap /** * Small persistent JSON cache for API responses that are expensive to request repeatedly. * * This cache is deliberately separate from downloads: it accelerates navigation/replays and lets * the UI reuse recent metadata, while the Downloads database/files remain the guaranteed offline * source of truth. */ object ApiResponseCache { @PublishedApi internal const val MAX_CACHE_BYTES = 32L * 1024L * 1024L @PublishedApi internal const val MAX_MEMORY_ENTRIES = 80 @PublishedApi internal data class MemoryEntry(val timestamp: Long, val json: String) @PublishedApi internal val memory = ConcurrentHashMap() @PublishedApi internal val cacheDir: File get() = File(LibreTubeApp.instance.cacheDir, "api_response_cache").apply { mkdirs() } suspend inline fun get( key: String, maxAgeMs: Long ): T? = withContext(Dispatchers.IO) { val now = System.currentTimeMillis() val hashed = hashKey(key) memory[hashed]?.let { entry -> val age = now - entry.timestamp if (age <= maxAgeMs) { return@withContext runCatching { JsonHelper.json.decodeFromString(entry.json) }.getOrNull() } } val file = File(cacheDir, "$hashed.json") if (!file.exists()) return@withContext null val text = runCatching { file.readText() }.getOrNull() ?: return@withContext null val newline = text.indexOf('\n') if (newline <= 0) return@withContext null val timestamp = text.substring(0, newline).toLongOrNull() ?: return@withContext null val age = now - timestamp if (age > maxAgeMs) { // Keep the entry on disk. A caller may intentionally retry with a larger stale TTL // after a network failure, and the size-based LRU cleanup will evict old files later. return@withContext null } val json = text.substring(newline + 1) memory[hashed] = MemoryEntry(timestamp, json) trimMemory() runCatching { JsonHelper.json.decodeFromString(json) }.getOrNull() } suspend inline fun put(key: String, value: T) = withContext(Dispatchers.IO) { val hashed = hashKey(key) val timestamp = System.currentTimeMillis() val json = JsonHelper.json.encodeToString(value) memory[hashed] = MemoryEntry(timestamp, json) trimMemory() val file = File(cacheDir, "$hashed.json") runCatching { file.writeText("$timestamp\n$json") file.setLastModified(timestamp) } trimDisk() } suspend fun remove(key: String) = withContext(Dispatchers.IO) { val hashed = hashKey(key) memory.remove(hashed) runCatching { File(cacheDir, "$hashed.json").delete() } Unit } @PublishedApi internal fun hashKey(value: String): String { val bytes = MessageDigest.getInstance("SHA-256").digest(value.toByteArray()) return bytes.joinToString("") { "%02x".format(it) } } @PublishedApi internal fun trimMemory() { if (memory.size <= MAX_MEMORY_ENTRIES) return memory.entries .sortedBy { it.value.timestamp } .take(memory.size - MAX_MEMORY_ENTRIES) .forEach { memory.remove(it.key) } } @PublishedApi internal fun trimDisk() { val files = cacheDir.listFiles()?.filter(File::isFile).orEmpty() var total = files.sumOf(File::length) if (total <= MAX_CACHE_BYTES) return files.sortedBy(File::lastModified).forEach { file -> if (total <= MAX_CACHE_BYTES) return total -= file.length() memory.remove(file.nameWithoutExtension) runCatching { file.delete() } } } }