diff --git a/app/shared/app-data/src/commonMain/kotlin/domain/mediasource/subscription/MediaSourceSubscription.kt b/app/shared/app-data/src/commonMain/kotlin/domain/mediasource/subscription/MediaSourceSubscription.kt index 51da438a7c..b13390b496 100644 --- a/app/shared/app-data/src/commonMain/kotlin/domain/mediasource/subscription/MediaSourceSubscription.kt +++ b/app/shared/app-data/src/commonMain/kotlin/domain/mediasource/subscription/MediaSourceSubscription.kt @@ -10,6 +10,7 @@ package me.him188.ani.app.domain.mediasource.subscription import kotlinx.serialization.Serializable +import me.him188.ani.app.data.models.ApiFailure import kotlin.time.Duration import kotlin.time.Duration.Companion.hours @@ -26,6 +27,7 @@ data class MediaSourceSubscription( @Serializable class UpdateError( val message: String?, + val failure: ApiFailure? = null, // serialization compatibility ) @Serializable diff --git a/app/shared/app-data/src/commonMain/kotlin/domain/mediasource/subscription/MediaSourceSubscriptionRequester.kt b/app/shared/app-data/src/commonMain/kotlin/domain/mediasource/subscription/MediaSourceSubscriptionRequester.kt index 8e61baa494..45b1e0e84e 100644 --- a/app/shared/app-data/src/commonMain/kotlin/domain/mediasource/subscription/MediaSourceSubscriptionRequester.kt +++ b/app/shared/app-data/src/commonMain/kotlin/domain/mediasource/subscription/MediaSourceSubscriptionRequester.kt @@ -10,21 +10,22 @@ package me.him188.ani.app.domain.mediasource.subscription import io.ktor.client.HttpClient +import io.ktor.client.plugins.ClientRequestException import io.ktor.client.request.get import io.ktor.client.request.parameter import io.ktor.client.statement.HttpResponse import io.ktor.client.statement.bodyAsChannel -import io.ktor.utils.io.CancellationException +import io.ktor.http.HttpStatusCode.Companion.UnprocessableEntity import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.first import kotlinx.serialization.json.io.decodeFromSource import me.him188.ani.app.data.models.ApiResponse -import me.him188.ani.app.data.models.map -import me.him188.ani.app.data.models.runApiRequest +import me.him188.ani.app.data.repository.RepositoryException import me.him188.ani.app.domain.mediasource.codec.MediaSourceCodecManager import me.him188.ani.app.platform.currentAniBuildConfig import me.him188.ani.utils.coroutines.withExceptionCollector import me.him188.ani.utils.ktor.toSource +import kotlin.coroutines.cancellation.CancellationException class MediaSourceSubscriptionRequester( private val client: Flow @@ -32,9 +33,10 @@ class MediaSourceSubscriptionRequester( /** * 执行网络请求, 下载新订阅数据. 遇到错误时将会返回 [ApiResponse.failure] */ + @Throws(RepositoryException::class, CancellationException::class) suspend fun request( subscription: MediaSourceSubscription, - ): ApiResponse { + ): SubscriptionUpdateData { val clientInstance = client.first() suspend fun HttpResponse.decode() = bodyAsChannel().toSource().use { @@ -47,7 +49,7 @@ class MediaSourceSubscriptionRequester( withExceptionCollector { // 首先直连 try { - return ApiResponse.success(clientInstance.get(subscription.url).decode()) + return clientInstance.get(subscription.url).decode() } catch (e: CancellationException) { throw e } catch (e: Exception) { @@ -55,12 +57,16 @@ class MediaSourceSubscriptionRequester( } // 失败则尝试代理服务 - return runApiRequest { + return try { clientInstance.get(currentAniBuildConfig.aniAuthServerUrl + "/v1/subs/proxy") { parameter("url", subscription.url) + }.decode() + } catch (e: ClientRequestException) { + if (e.response.status == UnprocessableEntity) { + // not in whitelist + throwLast() // ignore this exception, throw the previous one } - }.map { - it.decode() + throw e } } } diff --git a/app/shared/app-data/src/commonMain/kotlin/domain/mediasource/subscription/MediaSourceSubscriptionUpdater.kt b/app/shared/app-data/src/commonMain/kotlin/domain/mediasource/subscription/MediaSourceSubscriptionUpdater.kt index dcafae71d4..89b74cfdda 100644 --- a/app/shared/app-data/src/commonMain/kotlin/domain/mediasource/subscription/MediaSourceSubscriptionUpdater.kt +++ b/app/shared/app-data/src/commonMain/kotlin/domain/mediasource/subscription/MediaSourceSubscriptionUpdater.kt @@ -11,8 +11,12 @@ package me.him188.ani.app.domain.mediasource.subscription import kotlinx.coroutines.flow.first import me.him188.ani.app.data.models.ApiFailure -import me.him188.ani.app.data.models.ApiResponse -import me.him188.ani.app.data.models.valueOrElse +import me.him188.ani.app.data.repository.RepositoryAuthorizationException +import me.him188.ani.app.data.repository.RepositoryException +import me.him188.ani.app.data.repository.RepositoryNetworkException +import me.him188.ani.app.data.repository.RepositoryRateLimitedException +import me.him188.ani.app.data.repository.RepositoryServiceUnavailableException +import me.him188.ani.app.data.repository.RepositoryUnknownException import me.him188.ani.app.data.repository.media.MediaSourceSubscriptionRepository import me.him188.ani.app.domain.media.fetch.MediaSourceManager import me.him188.ani.app.domain.media.fetch.updateMediaSourceArguments @@ -59,33 +63,47 @@ class MediaSourceSubscriptionUpdater( logger.info { "Updating subscription: ${subscription.url}" } - kotlin.runCatching { - updateSubscription(subscription) - }.fold( - onSuccess = { count -> - this.subscriptions.update(subscription.subscriptionId) { old -> - old.copy( - lastUpdated = MediaSourceSubscription.LastUpdated( - currentTimeMillis, - mediaSourceCount = count, - error = null, - ), - ) - } - }, - onFailure = { error -> - logger.error(error) { "Failed to update subscription ${subscription.url}" } - this.subscriptions.update(subscription.subscriptionId) { old -> - old.copy( - lastUpdated = MediaSourceSubscription.LastUpdated( - currentTimeMillis, - mediaSourceCount = null, - error = UpdateError(error.toString()), - ), + suspend fun setResult(count: Int?, error: UpdateError? = null) { + this.subscriptions.update(subscription.subscriptionId) { old -> + old.copy( + lastUpdated = MediaSourceSubscription.LastUpdated( + currentTimeMillis, + mediaSourceCount = count, + error = error, + ), + ) + } + } + + try { + val count = updateSubscription(subscription) + setResult(count) + } catch (e: CancellationException) { + throw e + } catch (e: RepositoryException) { + when (e) { + is RepositoryAuthorizationException -> + setResult(null, UpdateError(e.toString(), ApiFailure.Unauthorized)) + + is RepositoryNetworkException -> + setResult(null, UpdateError(e.toString(), ApiFailure.NetworkError)) + + is RepositoryRateLimitedException -> + setResult( + null, + UpdateError("请求过于频繁", null), // TODO: 2024/12/3 use ApiFailure.RateLimited ) - } - }, - ) + + is RepositoryServiceUnavailableException -> + setResult(null, UpdateError(e.toString(), ApiFailure.ServiceUnavailable)) + + is RepositoryUnknownException -> + setResult(null, UpdateError(e.toString(), null)) + } + } catch (e: Exception) { + logger.error(e) { "Failed to update subscription ${subscription.url}" } + setResult(null, UpdateError(e.toString(), null)) + } } return subscriptions.minOf { subscription -> subscription.updatePeriod } @@ -105,12 +123,9 @@ class MediaSourceSubscriptionUpdater( } - @Throws(UpdateSubscriptionException::class, CancellationException::class) + @Throws(RepositoryException::class, CancellationException::class) private suspend fun updateSubscription(subscription: MediaSourceSubscription): Int { - val updateData = requester.request(subscription).valueOrElse { - throw RequestFailureException(it) - } - + val updateData = requester.request(subscription) val newArguments = updateData.exportedMediaSourceDataList.mediaSources.mapNotNull { runCatching { NewArgument(it, codecManager.decode(it)) @@ -193,6 +208,3 @@ class MediaSourceSubscriptionUpdater( } } } - -sealed class UpdateSubscriptionException(override val message: String?) : Exception() -class RequestFailureException(apiFailure: ApiFailure) : UpdateSubscriptionException("Request failed: $apiFailure") diff --git a/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/tabs/media/source/MediaSourceSubscriptionGroup.kt b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/tabs/media/source/MediaSourceSubscriptionGroup.kt index 657c759f99..dcb84c9b0a 100644 --- a/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/tabs/media/source/MediaSourceSubscriptionGroup.kt +++ b/app/shared/ui-settings/src/commonMain/kotlin/ui/settings/tabs/media/source/MediaSourceSubscriptionGroup.kt @@ -51,6 +51,7 @@ import androidx.compose.ui.text.input.ImeAction import androidx.compose.ui.unit.dp import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch +import me.him188.ani.app.data.models.ApiFailure import me.him188.ani.app.domain.mediasource.subscription.MediaSourceSubscription import me.him188.ani.app.tools.MonoTasker import me.him188.ani.app.tools.formatDateTime @@ -299,9 +300,10 @@ private fun SettingsScope.SubscriptionItem( private fun formatLastUpdated(lastUpdated: MediaSourceSubscription.LastUpdated?): String { if (lastUpdated == null) return "还未更新" val mediaSourceCount = lastUpdated.mediaSourceCount + val error = lastUpdated.error return when { - lastUpdated.error != null || mediaSourceCount == null -> { - "${formatDateTime(lastUpdated.timeMillis)}更新失败:${lastUpdated.error?.message}" + error != null || mediaSourceCount == null -> { + "${formatDateTime(lastUpdated.timeMillis)}更新失败:${formatError(error)}" } else -> { @@ -314,3 +316,13 @@ private fun formatLastUpdated(lastUpdated: MediaSourceSubscription.LastUpdated?) // null -> "${formatDateTime(lastUpdated.timeMillis)}更新成功" // } } + +private fun formatError(error: MediaSourceSubscription.UpdateError?): String { + if (error == null) return "未知错误" + val failre = error.failure ?: return error.message ?: "未知错误" + return when (failre) { + ApiFailure.NetworkError -> "网络错误,请检查网络连接" + ApiFailure.ServiceUnavailable -> "服务暂不可用,请稍后重试" + ApiFailure.Unauthorized -> "服务禁止访问,请联系服务提供商" + } +}