package com.suno.android.common_data.use_case import android.content.ContentValues import android.content.Context import android.net.Uri import android.os.Build import android.os.Environment import android.provider.MediaStore import arrow.core.Either import arrow.core.getOrElse import arrow.core.left import arrow.core.raise.either import arrow.core.right import com.suno.android.common_core_utils.DispatcherIO import com.suno.android.common_core_utils.Id import com.suno.android.common_core_utils.SunoLogger import com.suno.android.common_core_utils.model.Url import com.suno.android.common_data.mappers.hooks.DownloadStatus import com.suno.android.common_data.mappers.hooks.LocalHookData import com.suno.android.common_data.media.usecase.CleanupUriUseCase import com.suno.android.common_data.repos.HooksRepository import com.suno.android.common_networking.extensions.toThrowable import com.suno.android.common_networking.remote.download.HookDownloadService import dagger.hilt.android.qualifiers.ApplicationContext import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.delay import kotlinx.coroutines.withContext import retrofit2.HttpException import java.io.IOException import javax.inject.Inject import kotlin.time.Duration.Companion.seconds // 10 attempts @ 3 seconds = 30 seconds max private const val MAX_POLL_ATTEMPTS = 10 private const val POLL_INTERVAL_SECONDS = 3 private const val FALLBACK_NAME = "Hook" private const val SUNO_FOLDER = "Suno" sealed class HookDownloadError { data object Timeout : HookDownloadError() data object ProcessingError : HookDownloadError() data object DownloadUrlNotReady : HookDownloadError() data object FileDownloadFailed : HookDownloadError() data class NetworkError( val throwable: Throwable, ) : HookDownloadError() } data class HookDownloadResult( val hookId: Id, val hookPreviewUrl: Url?, val videoUri: Uri?, ) class DownloadHookUseCase @Inject constructor( loggerFactory: SunoLogger.Factory, @ApplicationContext private val context: Context, @DispatcherIO private val dispatcherIO: CoroutineDispatcher, private val hookDownloadService: HookDownloadService, private val hooksRepository: HooksRepository, private val cleanupMediaStoreUriUseCase: CleanupUriUseCase, ) { private val logger = loggerFactory.create(this@DownloadHookUseCase) suspend operator fun invoke( hook: LocalHookData, ): Either = withContext(dispatcherIO) { either { try { val downloadUrl = pollForDownloadUrl(hook.hookId).getOrElse { error -> raise(error) } downloadHookVideo( videoUrl = downloadUrl, hookTitle = hook.clip.nowPlayingTitle ?: FALLBACK_NAME, )?.let { videoUri -> HookDownloadResult( hookId = hook.hookId, hookPreviewUrl = hook.thumbnailImageUrl, videoUri = videoUri, ) } ?: run { raise(HookDownloadError.FileDownloadFailed) } } catch (e: HttpException) { logger.e(e) { "HTTP error during hook download: ${e.code()}" } raise(HookDownloadError.NetworkError(e)) } catch (e: IOException) { logger.e(e) { "I/O error during hook download" } raise(HookDownloadError.NetworkError(e)) } catch (e: SecurityException) { logger.e(e) { "Security error accessing gallery" } raise(HookDownloadError.NetworkError(e)) } } } @Suppress("ReturnCount") private suspend fun pollForDownloadUrl( hookId: Id, ): Either { var attempts = 0 val maxAttempts = MAX_POLL_ATTEMPTS val pollInterval = POLL_INTERVAL_SECONDS.seconds while (attempts < maxAttempts) { val response = hooksRepository.getHookDownloadUrl(hookId) .getOrElse { error -> logger.e(error.toThrowable()) { "Failed to fetch hook download URL, attempt ${attempts + 1}/$maxAttempts" } attempts++ delay(pollInterval) null } when (response?.status) { DownloadStatus.READY -> { return response.downloadUrl?.right() ?: HookDownloadError.DownloadUrlNotReady.left() } DownloadStatus.ERROR -> { return HookDownloadError.ProcessingError.left() } DownloadStatus.PROCESSING, null -> { // Continue polling attempts++ delay(pollInterval) } } } return HookDownloadError.Timeout.left() } private suspend fun downloadHookVideo( videoUrl: Url, hookTitle: String, ): Uri? { val uri = createSunoVideoMediaStoreUri(hookTitle).getOrElse { logger.w { "Failed to create MediaStore URI" } return null } try { // Download and write the video to the MediaStore URI val response = hookDownloadService.downloadVideoFile(fileUrl = videoUrl.url) val input = response.byteStream() input.use { inputStream -> context.contentResolver.openOutputStream(uri)?.use { outputStream -> inputStream.copyTo(outputStream) } ?: run { logger.w { "Failed to open output stream for URI: $uri" } cleanupMediaStoreUriUseCase(uri) return null } } return uri } catch (e: Exception) { logger.e(e) { "Failed to download hook video" } cleanupMediaStoreUriUseCase(uri) return null } } private fun createSunoVideoMediaStoreUri( name: String, ): Either = either { val contentValues = ContentValues().apply { put(MediaStore.Video.Media.DISPLAY_NAME, name) put(MediaStore.Video.Media.MIME_TYPE, "video/mp4") if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { put(MediaStore.Video.Media.RELATIVE_PATH, "${Environment.DIRECTORY_MOVIES}/$SUNO_FOLDER") } } val uri = context.contentResolver.insert( MediaStore.Video.Media.EXTERNAL_CONTENT_URI, contentValues, ) ?: run { raise(Unit) } uri } }