package com.github.libretube.api import com.github.libretube.api.obj.Channel import com.github.libretube.api.obj.ChannelTabResponse import com.github.libretube.api.obj.CommentsPage import com.github.libretube.api.obj.DeArrowContent import com.github.libretube.api.obj.Playlist import com.github.libretube.api.obj.SearchResult import com.github.libretube.api.obj.SegmentData import com.github.libretube.api.obj.StreamItem import com.github.libretube.api.obj.Streams import kotlinx.coroutines.CancellationException /** * Decorator that adds a short-lived device cache to the expensive read-only media calls. * Fresh data is preferred; on a temporary network failure a still-reasonable stale entry can be * returned so revisiting a screen does not immediately require another API round trip. */ class CachedMediaServiceRepository( private val delegate: MediaServiceRepository ) : MediaServiceRepository { private val backendKey: String = buildString { append(delegate::class.java.name) if (delegate is PipedMediaServiceRepository) { append(':') append(PipedMediaServiceRepository.apiUrl) } } override fun getTrendingCategories(): List = delegate.getTrendingCategories() override suspend fun getTrending(region: String, category: TrendingCategory): List = cached("trending:$region:${category.name}", HOME_TTL, HOME_STALE_TTL) { delegate.getTrending(region, category) } override suspend fun getStreams(videoId: String): Streams = cached("streams:$videoId", STREAM_TTL, STREAM_STALE_TTL) { delegate.getStreams(videoId) } override suspend fun getComments(videoId: String): CommentsPage = delegate.getComments(videoId) override suspend fun getSegments( videoId: String, category: List, actionType: List? ): SegmentData = cached( "segments:$videoId:${category.sorted().joinToString(",")}:${actionType.orEmpty().sorted().joinToString(",")}", SEGMENTS_TTL, SEGMENTS_STALE_TTL ) { delegate.getSegments(videoId, category, actionType) } override suspend fun getDeArrowContent(videoId: String): DeArrowContent? = cachedNullable("dearrow:$videoId", DEARROW_TTL, DEARROW_STALE_TTL) { delegate.getDeArrowContent(videoId) } override suspend fun getCommentsNextPage(videoId: String, nextPage: String): CommentsPage = delegate.getCommentsNextPage(videoId, nextPage) override suspend fun getSearchResults(searchQuery: String, filter: String): SearchResult = cached("search:$filter:${searchQuery.trim().lowercase()}", HOME_TTL, HOME_STALE_TTL) { delegate.getSearchResults(searchQuery, filter) } override suspend fun getSearchResultsNextPage( searchQuery: String, filter: String, nextPage: String ): SearchResult = cached( "search-next:$filter:${searchQuery.trim().lowercase()}:$nextPage", HOME_TTL, HOME_STALE_TTL ) { delegate.getSearchResultsNextPage(searchQuery, filter, nextPage) } override suspend fun getSuggestions(query: String): List = cached("suggest:${query.trim().lowercase()}", SUGGEST_TTL, SUGGEST_STALE_TTL) { delegate.getSuggestions(query) } override suspend fun getChannel(channelId: String): Channel = cached("channel:$channelId", DETAIL_TTL, DETAIL_STALE_TTL) { delegate.getChannel(channelId) } override suspend fun getChannelTab(data: String, nextPage: String?): ChannelTabResponse = cached("channel-tab:$data:${nextPage.orEmpty()}", DETAIL_TTL, DETAIL_STALE_TTL) { delegate.getChannelTab(data, nextPage) } override suspend fun getChannelByName(channelName: String): Channel = cached("channel-name:${channelName.trim().lowercase()}", DETAIL_TTL, DETAIL_STALE_TTL) { delegate.getChannelByName(channelName) } override suspend fun getChannelNextPage(channelId: String, nextPage: String): Channel = cached("channel-next:$channelId:$nextPage", DETAIL_TTL, DETAIL_STALE_TTL) { delegate.getChannelNextPage(channelId, nextPage) } override suspend fun getPlaylist(playlistId: String): Playlist = cached("playlist:$playlistId", DETAIL_TTL, DETAIL_STALE_TTL) { delegate.getPlaylist(playlistId) } override suspend fun getPlaylistNextPage(playlistId: String, nextPage: String): Playlist = cached("playlist-next:$playlistId:$nextPage", DETAIL_TTL, DETAIL_STALE_TTL) { delegate.getPlaylistNextPage(playlistId, nextPage) } private suspend inline fun cached( key: String, ttlMs: Long, staleTtlMs: Long, fetch: suspend () -> T ): T { val scopedKey = "$backendKey:$key" ApiResponseCache.get(scopedKey, ttlMs)?.let { return it } return try { val fresh = fetch() // Caching is an optimization only. A serialization/disk problem must never turn a // successful API response into a playback/navigation failure. try { ApiResponseCache.put(scopedKey, fresh) } catch (cancelled: CancellationException) { throw cancelled } catch (_: Exception) { // Ignore cache write failures and return the fresh response. } fresh } catch (throwable: Throwable) { if (throwable is CancellationException) throw throwable ApiResponseCache.get(scopedKey, staleTtlMs) ?: throw throwable } } private suspend inline fun cachedNullable( key: String, ttlMs: Long, staleTtlMs: Long, fetch: suspend () -> T? ): T? { val scopedKey = "$backendKey:$key" ApiResponseCache.get(scopedKey, ttlMs)?.let { return it } return try { val fresh = fetch() if (fresh != null) { try { ApiResponseCache.put(scopedKey, fresh) } catch (cancelled: CancellationException) { throw cancelled } catch (_: Exception) { // Ignore cache write failures and return the fresh response. } } fresh } catch (throwable: Throwable) { if (throwable is CancellationException) throw throwable ApiResponseCache.get(scopedKey, staleTtlMs) ?: throw throwable } } companion object { private const val STREAM_TTL = 10L * 60L * 1000L private const val STREAM_STALE_TTL = 60L * 60L * 1000L private const val HOME_TTL = 10L * 60L * 1000L private const val HOME_STALE_TTL = 6L * 60L * 60L * 1000L private const val DETAIL_TTL = 15L * 60L * 1000L private const val DETAIL_STALE_TTL = 12L * 60L * 60L * 1000L private const val SUGGEST_TTL = 5L * 60L * 1000L private const val SUGGEST_STALE_TTL = 60L * 60L * 1000L private const val SEGMENTS_TTL = 12L * 60L * 60L * 1000L private const val SEGMENTS_STALE_TTL = 3L * 24L * 60L * 60L * 1000L private const val DEARROW_TTL = 6L * 60L * 60L * 1000L private const val DEARROW_STALE_TTL = 24L * 60L * 60L * 1000L } }