package com.suno.android.media.hooks import androidx.annotation.VisibleForTesting import arrow.core.getOrElse import com.suno.android.common_core_utils.ApplicationCoroutineScope import com.suno.android.common_core_utils.Id import com.suno.android.common_core_utils.SunoLogger import com.suno.android.common_data.mappers.hooks.LocalHookData import com.suno.android.common_data.repos.HooksRepository import com.suno.android.common_networking.extensions.toThrowable import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import javax.inject.Inject import javax.inject.Singleton @VisibleForTesting const val FLUSH_INTERVAL_MS = 10_000L @VisibleForTesting const val MAX_BATCH_SIZE = 10 /** * Manages batched tracking of hook play counts with automatic flushing. * Batches are flushed when reaching [MAX_BATCH_SIZE] items or after [FLUSH_INTERVAL_MS] timeout. */ @Singleton class HooksPlayCountManager @Inject constructor( loggerFactory: SunoLogger.Factory, private val hooksRepository: HooksRepository, @ApplicationCoroutineScope private val scope: CoroutineScope, ) { private val logger = loggerFactory.create(this@HooksPlayCountManager) @VisibleForTesting val playCountsBatch = mutableMapOf, Int>() private val playCountsBatchMutex = Mutex() private var flushTimer: Job? = null fun markAsWatched( hookId: Id, ) { scope.launch { addToBatch(hookId) } } private suspend fun addToBatch( hookId: Id, ) { val updatedBatchSize = playCountsBatchMutex.withLock { playCountsBatch[hookId] = playCountsBatch.getOrDefault(hookId, 0) + 1 logger.d { "Added hook id: $hookId to batch with updated play count: ${playCountsBatch[hookId]}" } playCountsBatch.size } if (updatedBatchSize == 1) { startFlushTimer() } else if (updatedBatchSize >= MAX_BATCH_SIZE) { flushPlayCounts() } } private fun startFlushTimer() { cancelFlushTimer() flushTimer = scope.launch { logger.d { "Started flush timer: $FLUSH_INTERVAL_MS ms" } delay(FLUSH_INTERVAL_MS) flushPlayCounts() } } fun flushPlayCounts() { cancelFlushTimer() scope.launch { val batchToFlush = playCountsBatchMutex.withLock { val batch = playCountsBatch.toMap() playCountsBatch.clear() batch } if (batchToFlush.isEmpty()) { logger.d { "Nothing to flush" } return@launch } hooksRepository.updatePlayCounts(batchToFlush).getOrElse { error -> logger.e(error.toThrowable()) { "Failed to flush play counts, re-adding back to batch to retry" } playCountsBatchMutex.withLock { batchToFlush.forEach { (hookId, count) -> playCountsBatch[hookId] = playCountsBatch.getOrDefault(hookId, 0) + count } } if (flushTimer?.isActive != true) { startFlushTimer() } return@launch } logger.d { "Successfully flushed $batchToFlush" } } } private fun cancelFlushTimer() { flushTimer?.cancel() flushTimer = null logger.d { "Cancelled flush timer" } } }