From 22d32730b6cc17524aaa2f952fdb0e03a0fff46f Mon Sep 17 00:00:00 2001 From: Taneesha Reyyi Date: Wed, 3 Jun 2026 22:18:25 +0530 Subject: [PATCH] feat: add offline metadata and artwork caching --- app/build.gradle.kts | 5 + .../kotlin/com/arflix/tv/ArflixApplication.kt | 20 +++ .../tv/data/local/OfflineMetadataCache.kt | 154 ++++++++++++++++++ .../tv/data/local/OfflineMetadataDao.kt | 27 +++ .../tv/data/local/OfflineMetadataDatabase.kt | 14 ++ .../tv/data/local/OfflineMetadataEntity.kt | 14 ++ .../arflix/tv/data/local/OfflineSearchDao.kt | 24 +++ .../tv/data/local/OfflineSearchEntity.kt | 11 ++ .../tv/data/repository/MediaRepository.kt | 84 +++++++--- .../main/kotlin/com/arflix/tv/di/AppModule.kt | 19 ++- .../tv/worker/MediaMetadataRefreshWorker.kt | 63 +++++++ 11 files changed, 414 insertions(+), 21 deletions(-) create mode 100644 app/src/main/kotlin/com/arflix/tv/data/local/OfflineMetadataCache.kt create mode 100644 app/src/main/kotlin/com/arflix/tv/data/local/OfflineMetadataDao.kt create mode 100644 app/src/main/kotlin/com/arflix/tv/data/local/OfflineMetadataDatabase.kt create mode 100644 app/src/main/kotlin/com/arflix/tv/data/local/OfflineMetadataEntity.kt create mode 100644 app/src/main/kotlin/com/arflix/tv/data/local/OfflineSearchDao.kt create mode 100644 app/src/main/kotlin/com/arflix/tv/data/local/OfflineSearchEntity.kt create mode 100644 app/src/main/kotlin/com/arflix/tv/worker/MediaMetadataRefreshWorker.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 2df2653a..0b847e0c 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -260,6 +260,11 @@ dependencies { implementation("io.coil-kt:coil-svg:2.5.0") implementation("com.google.zxing:core:3.5.3") + // Local metadata persistence + implementation("androidx.room:room-runtime:2.6.0") + implementation("androidx.room:room-ktx:2.6.0") + ksp("androidx.room:room-compiler:2.6.0") + // Supabase (optional - for cloud sync) implementation("io.github.jan-tennert.supabase:postgrest-kt:2.0.4") implementation("io.github.jan-tennert.supabase:gotrue-kt:2.0.4") diff --git a/app/src/main/kotlin/com/arflix/tv/ArflixApplication.kt b/app/src/main/kotlin/com/arflix/tv/ArflixApplication.kt index 2d598ba1..4c5caac1 100644 --- a/app/src/main/kotlin/com/arflix/tv/ArflixApplication.kt +++ b/app/src/main/kotlin/com/arflix/tv/ArflixApplication.kt @@ -37,6 +37,7 @@ import com.arflix.tv.util.CrashlyticsProvider import com.arflix.tv.util.DeviceType import com.arflix.tv.util.SentryCrashReporter import com.arflix.tv.util.detectDeviceType +import com.arflix.tv.worker.MediaMetadataRefreshWorker import com.arflix.tv.worker.TraktSyncWorker import dagger.hilt.android.HiltAndroidApp import kotlinx.coroutines.CoroutineScope @@ -132,6 +133,8 @@ class ArflixApplication : Application(), Configuration.Provider, ImageLoaderFact runCatching { appUsageAnalyticsRepository.recordAppOpen() } } + scheduleMediaMetadataRefreshIfNeeded() + // Observe auth state: start realtime on login, stop on logout appScope.launch { authRepository.authState.collectLatest { state -> @@ -274,6 +277,23 @@ class ArflixApplication : Application(), Configuration.Provider, ImageLoaderFact ) } + fun scheduleMediaMetadataRefreshIfNeeded() { + val constraints = Constraints.Builder() + .setRequiredNetworkType(NetworkType.CONNECTED) + .setRequiresBatteryNotLow(true) + .build() + + val refreshRequest = PeriodicWorkRequestBuilder(24, TimeUnit.HOURS) + .setConstraints(constraints) + .build() + + WorkManager.getInstance(this).enqueueUniquePeriodicWork( + MediaMetadataRefreshWorker.WORK_NAME, + ExistingPeriodicWorkPolicy.KEEP, + refreshRequest + ) + } + companion object { lateinit var instance: ArflixApplication private set diff --git a/app/src/main/kotlin/com/arflix/tv/data/local/OfflineMetadataCache.kt b/app/src/main/kotlin/com/arflix/tv/data/local/OfflineMetadataCache.kt new file mode 100644 index 00000000..a78e9020 --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/data/local/OfflineMetadataCache.kt @@ -0,0 +1,154 @@ +package com.arflix.tv.data.local + +import com.arflix.tv.data.model.CastMember +import com.arflix.tv.data.model.MediaItem +import com.arflix.tv.data.model.MediaType +import com.google.gson.Gson +import com.google.gson.reflect.TypeToken +import java.net.URLEncoder +import java.nio.charset.StandardCharsets +import java.util.Locale +import javax.inject.Inject +import javax.inject.Singleton + +@Singleton +class OfflineMetadataCache @Inject constructor( + private val database: OfflineMetadataDatabase +) { + + companion object { + const val RECORD_KIND_DETAILS = "details" + const val RECORD_KIND_CAST = "cast" + const val RECORD_KIND_SEARCH = "search" + + private val gson = Gson() + private val mediaItemListType = object : TypeToken>() {}.type + private val castMemberListType = object : TypeToken>() {}.type + } + + private fun metadataKey(mediaType: MediaType, mediaId: Int, kind: String): String { + return "${mediaType.name}_$mediaId_$kind" + } + + private fun normalizedSearchQuery(query: String): String { + return URLEncoder.encode(query.trim().lowercase(Locale.ROOT), StandardCharsets.UTF_8.toString()) + } + + suspend fun getCachedMediaDetails(mediaType: MediaType, mediaId: Int, maxAgeMs: Long): MediaItem? { + val entity = database.offlineMetadataDao().get( + mediaType.name, + mediaId, + RECORD_KIND_DETAILS + ) ?: return null + return if (System.currentTimeMillis() - entity.lastUpdatedMillis <= maxAgeMs) { + gson.fromJson(entity.payloadJson, MediaItem::class.java) + } else { + null + } + } + + suspend fun getCachedMediaDetailsStale(mediaType: MediaType, mediaId: Int): MediaItem? { + val entity = database.offlineMetadataDao().get( + mediaType.name, + mediaId, + RECORD_KIND_DETAILS + ) ?: return null + return gson.fromJson(entity.payloadJson, MediaItem::class.java) + } + + suspend fun putCachedMediaDetails(mediaType: MediaType, mediaId: Int, item: MediaItem) { + val entity = OfflineMetadataEntity( + key = metadataKey(mediaType, mediaId, RECORD_KIND_DETAILS), + mediaType = mediaType.name, + mediaId = mediaId, + recordKind = RECORD_KIND_DETAILS, + payloadJson = gson.toJson(item), + lastUpdatedMillis = System.currentTimeMillis() + ) + database.offlineMetadataDao().upsert(entity) + } + + suspend fun getCachedCast(mediaType: MediaType, mediaId: Int, maxAgeMs: Long): List? { + val entity = database.offlineMetadataDao().get( + mediaType.name, + mediaId, + RECORD_KIND_CAST + ) ?: return null + return if (System.currentTimeMillis() - entity.lastUpdatedMillis <= maxAgeMs) { + gson.fromJson(entity.payloadJson, castMemberListType) + } else { + null + } + } + + suspend fun getCachedCastStale(mediaType: MediaType, mediaId: Int): List? { + val entity = database.offlineMetadataDao().get( + mediaType.name, + mediaId, + RECORD_KIND_CAST + ) ?: return null + return gson.fromJson(entity.payloadJson, castMemberListType) + } + + suspend fun putCachedCast(mediaType: MediaType, mediaId: Int, cast: List) { + val entity = OfflineMetadataEntity( + key = metadataKey(mediaType, mediaId, RECORD_KIND_CAST), + mediaType = mediaType.name, + mediaId = mediaId, + recordKind = RECORD_KIND_CAST, + payloadJson = gson.toJson(cast), + lastUpdatedMillis = System.currentTimeMillis() + ) + database.offlineMetadataDao().upsert(entity) + } + + suspend fun getCachedSearchResults(query: String, maxAgeMs: Long): List? { + val entity = database.offlineSearchDao().get(normalizedSearchQuery(query)) ?: return null + return if (System.currentTimeMillis() - entity.lastUpdatedMillis <= maxAgeMs) { + gson.fromJson(entity.payloadJson, mediaItemListType) + } else { + null + } + } + + suspend fun putCachedSearchResults(query: String, results: List) { + val entity = OfflineSearchEntity( + query = normalizedSearchQuery(query), + payloadJson = gson.toJson(results), + lastUpdatedMillis = System.currentTimeMillis() + ) + database.offlineSearchDao().upsert(entity) + } + + suspend fun pruneOldSearchResults(maxAgeMs: Long) { + val staleBefore = System.currentTimeMillis() - maxAgeMs + database.offlineSearchDao().deleteOlderThan(staleBefore) + } + + suspend fun getStaleRecordKeys(recordKind: String, maxAgeMs: Long, limit: Int): List> { + val staleBefore = System.currentTimeMillis() - maxAgeMs + return database.offlineMetadataDao() + .getStaleEntities(recordKind, staleBefore, limit) + .mapNotNull { entity -> + runCatching { + MediaType.valueOf(entity.mediaType) to entity.mediaId + }.getOrNull() + } + } + + suspend fun clearAll() { + database.offlineMetadataDao().clearAll() + database.offlineSearchDao().clearAll() + } + + suspend fun getCacheStats(): CacheStats { + val metadataCount = database.offlineMetadataDao().count() + val searchCount = database.offlineSearchDao().count() + return CacheStats(metadataCount, searchCount) + } + + data class CacheStats( + val metadataCount: Int, + val searchCount: Int + ) +} diff --git a/app/src/main/kotlin/com/arflix/tv/data/local/OfflineMetadataDao.kt b/app/src/main/kotlin/com/arflix/tv/data/local/OfflineMetadataDao.kt new file mode 100644 index 00000000..98f90206 --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/data/local/OfflineMetadataDao.kt @@ -0,0 +1,27 @@ +package com.arflix.tv.data.local + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query + +@Dao +interface OfflineMetadataDao { + @Query("SELECT * FROM offline_metadata WHERE mediaType = :mediaType AND mediaId = :mediaId AND recordKind = :recordKind LIMIT 1") + suspend fun get(mediaType: String, mediaId: Int, recordKind: String): OfflineMetadataEntity? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(entity: OfflineMetadataEntity) + + @Query("DELETE FROM offline_metadata WHERE key = :key") + suspend fun delete(key: String) + + @Query("SELECT * FROM offline_metadata WHERE recordKind = :recordKind AND lastUpdatedMillis < :staleBefore LIMIT :limit") + suspend fun getStaleEntities(recordKind: String, staleBefore: Long, limit: Int): List + + @Query("SELECT COUNT(*) FROM offline_metadata") + suspend fun count(): Int + + @Query("DELETE FROM offline_metadata") + suspend fun clearAll() +} diff --git a/app/src/main/kotlin/com/arflix/tv/data/local/OfflineMetadataDatabase.kt b/app/src/main/kotlin/com/arflix/tv/data/local/OfflineMetadataDatabase.kt new file mode 100644 index 00000000..43a1ce46 --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/data/local/OfflineMetadataDatabase.kt @@ -0,0 +1,14 @@ +package com.arflix.tv.data.local + +import androidx.room.Database +import androidx.room.RoomDatabase + +@Database( + entities = [OfflineMetadataEntity::class, OfflineSearchEntity::class], + version = 1, + exportSchema = false +) +abstract class OfflineMetadataDatabase : RoomDatabase() { + abstract fun offlineMetadataDao(): OfflineMetadataDao + abstract fun offlineSearchDao(): OfflineSearchDao +} diff --git a/app/src/main/kotlin/com/arflix/tv/data/local/OfflineMetadataEntity.kt b/app/src/main/kotlin/com/arflix/tv/data/local/OfflineMetadataEntity.kt new file mode 100644 index 00000000..479a8680 --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/data/local/OfflineMetadataEntity.kt @@ -0,0 +1,14 @@ +package com.arflix.tv.data.local + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "offline_metadata") +data class OfflineMetadataEntity( + @PrimaryKey val key: String, + val mediaType: String, + val mediaId: Int, + val recordKind: String, + val payloadJson: String, + val lastUpdatedMillis: Long +) diff --git a/app/src/main/kotlin/com/arflix/tv/data/local/OfflineSearchDao.kt b/app/src/main/kotlin/com/arflix/tv/data/local/OfflineSearchDao.kt new file mode 100644 index 00000000..ce2b62ee --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/data/local/OfflineSearchDao.kt @@ -0,0 +1,24 @@ +package com.arflix.tv.data.local + +import androidx.room.Dao +import androidx.room.Insert +import androidx.room.OnConflictStrategy +import androidx.room.Query + +@Dao +interface OfflineSearchDao { + @Query("SELECT * FROM offline_search WHERE query = :query LIMIT 1") + suspend fun get(query: String): OfflineSearchEntity? + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun upsert(entity: OfflineSearchEntity) + + @Query("DELETE FROM offline_search WHERE lastUpdatedMillis < :staleBefore") + suspend fun deleteOlderThan(staleBefore: Long) + + @Query("SELECT COUNT(*) FROM offline_search") + suspend fun count(): Int + + @Query("DELETE FROM offline_search") + suspend fun clearAll() +} diff --git a/app/src/main/kotlin/com/arflix/tv/data/local/OfflineSearchEntity.kt b/app/src/main/kotlin/com/arflix/tv/data/local/OfflineSearchEntity.kt new file mode 100644 index 00000000..f0a6f75c --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/data/local/OfflineSearchEntity.kt @@ -0,0 +1,11 @@ +package com.arflix.tv.data.local + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "offline_search") +data class OfflineSearchEntity( + @PrimaryKey val query: String, + val payloadJson: String, + val lastUpdatedMillis: Long +) diff --git a/app/src/main/kotlin/com/arflix/tv/data/repository/MediaRepository.kt b/app/src/main/kotlin/com/arflix/tv/data/repository/MediaRepository.kt index 2a5a217f..664e2b20 100644 --- a/app/src/main/kotlin/com/arflix/tv/data/repository/MediaRepository.kt +++ b/app/src/main/kotlin/com/arflix/tv/data/repository/MediaRepository.kt @@ -29,6 +29,7 @@ import com.arflix.tv.data.model.CollectionTileShape import com.arflix.tv.data.model.Episode import com.arflix.tv.data.model.MediaItem import com.arflix.tv.data.model.MediaType +import com.arflix.tv.data.local.OfflineMetadataCache import com.arflix.tv.data.model.PersonDetails import com.arflix.tv.data.model.Review import com.arflix.tv.util.CatalogUrlParser @@ -86,7 +87,8 @@ class MediaRepository @Inject constructor( private val traktApi: TraktApi, private val okHttpClient: OkHttpClient, private val streamRepository: StreamRepository, - private val homeServerRepository: HomeServerRepository + private val homeServerRepository: HomeServerRepository, + private val offlineMetadataCache: OfflineMetadataCache ) { data class CategoryPageResult( @@ -119,6 +121,10 @@ class MediaRepository @Inject constructor( private val reviewsCache = mutableMapOf>>() private val watchProvidersCache = mutableMapOf>() private val seasonEpisodesCache = mutableMapOf>>() + + private val DETAILS_CACHE_TTL_MS = 7 * 24 * 60 * 60 * 1000L + private val CAST_CACHE_TTL_MS = 14 * 24 * 60 * 60 * 1000L + private val SEARCH_CACHE_TTL_MS = 3 * 24 * 60 * 60 * 1000L private val imdbRatingCache = ConcurrentHashMap>() private val imdbEpisodeRatingsCache = ConcurrentHashMap, String>>>() private val imdbRatingsByIdCache = ConcurrentHashMap>() @@ -2760,17 +2766,31 @@ class MediaRepository @Inject constructor( } } - val item = coroutineScope { - val detailsDeferred = async { tmdbApi.getMovieDetails(movieId, apiKey, language = contentLanguage) } - val externalIdsDeferred = async { resolveExternalIds(MediaType.MOVIE, movieId) } + offlineMetadataCache.getCachedMediaDetails(MediaType.MOVIE, movieId, DETAILS_CACHE_TTL_MS)?.let { + detailsCache[cacheKey] = CacheEntry(it, System.currentTimeMillis()) + fullDetailsCacheKeys.add(cacheKey) + return it + } + + return try { + val item = coroutineScope { + val detailsDeferred = async { tmdbApi.getMovieDetails(movieId, apiKey, language = contentLanguage) } + val externalIdsDeferred = async { resolveExternalIds(MediaType.MOVIE, movieId) } - val details = detailsDeferred.await() - val imdbId = externalIdsDeferred.await()?.imdbId?.also { cacheImdbId(MediaType.MOVIE, movieId, it) } - val imdbRating = imdbId?.let { getImdbRating(MediaType.MOVIE, movieId, it) } - details.toMediaItem().copy(imdbRating = imdbRating.orEmpty()) + val details = detailsDeferred.await() + val imdbId = externalIdsDeferred.await()?.imdbId?.also { cacheImdbId(MediaType.MOVIE, movieId, it) } + val imdbRating = imdbId?.let { getImdbRating(MediaType.MOVIE, movieId, it) } + details.toMediaItem().copy(imdbRating = imdbRating.orEmpty()) + } + cacheFullDetailsItem(item) + offlineMetadataCache.putCachedMediaDetails(MediaType.MOVIE, movieId, item) + item + } catch (error: Throwable) { + offlineMetadataCache.getCachedMediaDetailsStale(MediaType.MOVIE, movieId)?.also { + detailsCache[cacheKey] = CacheEntry(it, System.currentTimeMillis()) + fullDetailsCacheKeys.add(cacheKey) + } ?: throw error } - cacheFullDetailsItem(item) - return item } /** @@ -2789,17 +2809,31 @@ class MediaRepository @Inject constructor( } } - val item = coroutineScope { - val detailsDeferred = async { tmdbApi.getTvDetails(tvId, apiKey, language = contentLanguage) } - val externalIdsDeferred = async { resolveExternalIds(MediaType.TV, tvId) } + offlineMetadataCache.getCachedMediaDetails(MediaType.TV, tvId, DETAILS_CACHE_TTL_MS)?.let { + detailsCache[cacheKey] = CacheEntry(it, System.currentTimeMillis()) + fullDetailsCacheKeys.add(cacheKey) + return it + } - val details = detailsDeferred.await() - val imdbId = externalIdsDeferred.await()?.imdbId?.also { cacheImdbId(MediaType.TV, tvId, it) } - val imdbRating = imdbId?.let { getImdbRating(MediaType.TV, tvId, it) } - details.toMediaItem().copy(imdbRating = imdbRating.orEmpty()) + return try { + val item = coroutineScope { + val detailsDeferred = async { tmdbApi.getTvDetails(tvId, apiKey, language = contentLanguage) } + val externalIdsDeferred = async { resolveExternalIds(MediaType.TV, tvId) } + + val details = detailsDeferred.await() + val imdbId = externalIdsDeferred.await()?.imdbId?.also { cacheImdbId(MediaType.TV, tvId, it) } + val imdbRating = imdbId?.let { getImdbRating(MediaType.TV, tvId, it) } + details.toMediaItem().copy(imdbRating = imdbRating.orEmpty()) + } + cacheFullDetailsItem(item) + offlineMetadataCache.putCachedMediaDetails(MediaType.TV, tvId, item) + item + } catch (error: Throwable) { + offlineMetadataCache.getCachedMediaDetailsStale(MediaType.TV, tvId)?.also { + detailsCache[cacheKey] = CacheEntry(it, System.currentTimeMillis()) + fullDetailsCacheKeys.add(cacheKey) + } ?: throw error } - cacheFullDetailsItem(item) - return item } /** @@ -2897,6 +2931,11 @@ class MediaRepository @Inject constructor( val cacheKey = "${mediaType}_cast_$mediaId" getFromCache(castCache, cacheKey)?.let { return it } + offlineMetadataCache.getCachedCast(mediaType, mediaId, CAST_CACHE_TTL_MS)?.let { + castCache[cacheKey] = CacheEntry(it, System.currentTimeMillis()) + return it + } + val type = if (mediaType == MediaType.TV) "tv" else "movie" val credits = tmdbApi.getCredits(type, mediaId, apiKey, language = contentLanguage) @@ -2914,6 +2953,7 @@ class MediaRepository @Inject constructor( castMembers } castCache[cacheKey] = CacheEntry(result, System.currentTimeMillis()) + offlineMetadataCache.putCachedCast(mediaType, mediaId, result) return result } @@ -3030,6 +3070,11 @@ class MediaRepository @Inject constructor( * Search media */ suspend fun search(query: String): List { + offlineMetadataCache.getCachedSearchResults(query, SEARCH_CACHE_TTL_MS)?.let { + cacheItems(it) + return it + } + val results = tmdbApi.searchMulti(apiKey, query, language = contentLanguage) val items = results.results .filter { it.mediaType == "movie" || it.mediaType == "tv" } @@ -3039,6 +3084,7 @@ class MediaRepository @Inject constructor( ) } cacheItems(items) + offlineMetadataCache.putCachedSearchResults(query, items) return items } diff --git a/app/src/main/kotlin/com/arflix/tv/di/AppModule.kt b/app/src/main/kotlin/com/arflix/tv/di/AppModule.kt index 7dfc4363..b5d6b68e 100644 --- a/app/src/main/kotlin/com/arflix/tv/di/AppModule.kt +++ b/app/src/main/kotlin/com/arflix/tv/di/AppModule.kt @@ -4,10 +4,13 @@ import android.content.Context import com.arflix.tv.data.api.AniSkipApi import com.arflix.tv.data.api.ArmApi import com.arflix.tv.data.api.IntroDbApi -import com.arflix.tv.data.api.StreamApi -import com.arflix.tv.data.api.SupabaseApi +import android.content.Context +import androidx.room.Room import com.arflix.tv.data.api.TmdbApi import com.arflix.tv.data.api.TraktApi +import com.arflix.tv.data.api.SupabaseApi +import com.arflix.tv.data.api.StreamApi +import com.arflix.tv.data.local.OfflineMetadataDatabase import com.arflix.tv.network.OkHttpProvider import com.arflix.tv.util.Constants import dagger.Module @@ -81,6 +84,18 @@ object AppModule { .create(StreamApi::class.java) } + @Provides + @Singleton + fun provideOfflineMetadataDatabase(@ApplicationContext context: Context): OfflineMetadataDatabase { + return Room.databaseBuilder( + context, + OfflineMetadataDatabase::class.java, + "offline_metadata.db" + ) + .fallbackToDestructiveMigration() + .build() + } + // Skip intro providers (IntroDB + AniSkip + ARM). @Provides diff --git a/app/src/main/kotlin/com/arflix/tv/worker/MediaMetadataRefreshWorker.kt b/app/src/main/kotlin/com/arflix/tv/worker/MediaMetadataRefreshWorker.kt new file mode 100644 index 00000000..d0b9d534 --- /dev/null +++ b/app/src/main/kotlin/com/arflix/tv/worker/MediaMetadataRefreshWorker.kt @@ -0,0 +1,63 @@ +package com.arflix.tv.worker + +import android.content.Context +import androidx.hilt.work.HiltWorker +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import com.arflix.tv.data.local.OfflineMetadataCache +import com.arflix.tv.data.repository.MediaRepository +import dagger.assisted.Assisted +import dagger.assisted.AssistedInject + +@HiltWorker +class MediaMetadataRefreshWorker @AssistedInject constructor( + @Assisted context: Context, + @Assisted params: WorkerParameters, + private val mediaRepository: MediaRepository, + private val offlineMetadataCache: OfflineMetadataCache +) : CoroutineWorker(context, params) { + + override suspend fun doWork(): Result { + return try { + val staleDetails = offlineMetadataCache.getStaleRecordKeys( + OfflineMetadataCache.RECORD_KIND_DETAILS, + MediaMetadataRefreshWorker.REFRESH_DETAILS_INTERVAL_MS, + 30 + ) + staleDetails.forEach { (mediaType, mediaId) -> + try { + if (mediaType == com.arflix.tv.data.model.MediaType.MOVIE) { + mediaRepository.getMovieDetails(mediaId) + } else { + mediaRepository.getTvDetails(mediaId) + } + } catch (_: Exception) { + // Continue refreshing remaining records. + } + } + + val staleCast = offlineMetadataCache.getStaleRecordKeys( + OfflineMetadataCache.RECORD_KIND_CAST, + MediaMetadataRefreshWorker.REFRESH_CAST_INTERVAL_MS, + 30 + ) + staleCast.forEach { (mediaType, mediaId) -> + try { + mediaRepository.getCast(mediaType, mediaId) + } catch (_: Exception) { + // Continue refreshing remaining records. + } + } + + Result.success() + } catch (error: Throwable) { + Result.retry() + } + } + + companion object { + const val WORK_NAME = "MediaMetadataRefreshWorker" + private const val REFRESH_DETAILS_INTERVAL_MS = 7 * 24 * 60 * 60 * 1000L + private const val REFRESH_CAST_INTERVAL_MS = 14 * 24 * 60 * 60 * 1000L + } +}