New mirror switching approach

master
Koitharu 10 months ago
parent fc7f5f2cf9
commit 14f185393b
Signed by: Koitharu
GPG Key ID: 676DEE768C17A9D7

@ -1,162 +0,0 @@
package org.koitharu.kotatsu.core.network
import androidx.collection.ArraySet
import dagger.Lazy
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.runInterruptible
import okhttp3.Interceptor
import okhttp3.Request
import okhttp3.Response
import okhttp3.ResponseBody
import okhttp3.ResponseBody.Companion.toResponseBody
import okhttp3.internal.canParseAsIpAddress
import okhttp3.internal.closeQuietly
import okhttp3.internal.publicsuffix.PublicSuffixDatabase
import org.koitharu.kotatsu.core.parser.MangaRepository
import org.koitharu.kotatsu.core.parser.ParserMangaRepository
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.model.MangaSource
import java.util.EnumMap
import javax.inject.Inject
import javax.inject.Singleton
@Singleton
class MirrorSwitchInterceptor @Inject constructor(
private val mangaRepositoryFactoryLazy: Lazy<MangaRepository.Factory>,
private val settings: AppSettings,
) : Interceptor {
private val locks = EnumMap<MangaParserSource, Any>(MangaParserSource::class.java)
private val blacklist = EnumMap<MangaParserSource, MutableSet<String>>(MangaParserSource::class.java)
val isEnabled: Boolean
get() = settings.isMirrorSwitchingAvailable
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request()
if (!isEnabled) {
return chain.proceed(request)
}
return try {
val response = chain.proceed(request)
if (response.isFailed) {
val responseCopy = response.copy()
response.closeQuietly()
trySwitchMirror(request, chain)?.also {
responseCopy.closeQuietly()
} ?: responseCopy
} else {
response
}
} catch (e: Exception) {
trySwitchMirror(request, chain) ?: throw e
}
}
suspend fun trySwitchMirror(repository: ParserMangaRepository): Boolean = runInterruptible(Dispatchers.Default) {
if (!isEnabled) {
return@runInterruptible false
}
val mirrors = repository.getAvailableMirrors()
if (mirrors.size <= 1) {
return@runInterruptible false
}
synchronized(obtainLock(repository.source)) {
val currentMirror = repository.domain
if (currentMirror !in mirrors) {
return@synchronized false
}
addToBlacklist(repository.source, currentMirror)
val newMirror = mirrors.firstOrNull { x ->
x != currentMirror && !isBlacklisted(repository.source, x)
} ?: return@synchronized false
repository.domain = newMirror
true
}
}
fun rollback(repository: ParserMangaRepository, oldMirror: String) = synchronized(obtainLock(repository.source)) {
blacklist[repository.source]?.remove(oldMirror)
repository.domain = oldMirror
}
private fun trySwitchMirror(request: Request, chain: Interceptor.Chain): Response? {
val source = request.tag(MangaSource::class.java) ?: return null
val repository = mangaRepositoryFactoryLazy.get().create(source) as? ParserMangaRepository ?: return null
val mirrors = repository.getAvailableMirrors()
if (mirrors.isEmpty()) {
return null
}
return synchronized(obtainLock(repository.source)) {
tryMirrors(repository, mirrors, chain, request)
}
}
private fun tryMirrors(
repository: ParserMangaRepository,
mirrors: List<String>,
chain: Interceptor.Chain,
request: Request,
): Response? {
val url = request.url
val currentDomain = url.topPrivateDomain()
if (currentDomain !in mirrors) {
return null
}
val urlBuilder = url.newBuilder()
for (mirror in mirrors) {
if (mirror == currentDomain || isBlacklisted(repository.source, mirror)) {
continue
}
val newHost = hostOf(url.host, mirror) ?: continue
val newRequest = request.newBuilder()
.url(urlBuilder.host(newHost).build())
.build()
val response = chain.proceed(newRequest)
if (response.isFailed) {
addToBlacklist(repository.source, mirror)
response.closeQuietly()
} else {
repository.domain = mirror
return response
}
}
return null
}
private val Response.isFailed: Boolean
get() = code in 400..599
private fun hostOf(host: String, newDomain: String): String? {
if (newDomain.canParseAsIpAddress()) {
return newDomain
}
val domain = PublicSuffixDatabase.get().getEffectiveTldPlusOne(host) ?: return null
return host.removeSuffix(domain) + newDomain
}
private fun Response.copy(): Response {
return newBuilder()
.body(body?.copy())
.build()
}
private fun ResponseBody.copy(): ResponseBody {
return source().readByteArray().toResponseBody(contentType())
}
private fun obtainLock(source: MangaParserSource): Any = locks.getOrPut(source) {
Any()
}
private fun isBlacklisted(source: MangaParserSource, domain: String): Boolean {
return blacklist[source]?.contains(domain) == true
}
private fun addToBlacklist(source: MangaParserSource, domain: String) {
blacklist.getOrPut(source) {
ArraySet(2)
}.add(domain)
}
}

@ -93,11 +93,9 @@ interface NetworkModule {
fun provideMangaHttpClient( fun provideMangaHttpClient(
@BaseHttpClient baseClient: OkHttpClient, @BaseHttpClient baseClient: OkHttpClient,
commonHeadersInterceptor: CommonHeadersInterceptor, commonHeadersInterceptor: CommonHeadersInterceptor,
mirrorSwitchInterceptor: MirrorSwitchInterceptor,
): OkHttpClient = baseClient.newBuilder().apply { ): OkHttpClient = baseClient.newBuilder().apply {
addNetworkInterceptor(CacheLimitInterceptor()) addNetworkInterceptor(CacheLimitInterceptor())
addInterceptor(commonHeadersInterceptor) addInterceptor(commonHeadersInterceptor)
addInterceptor(mirrorSwitchInterceptor)
}.build() }.build()
} }

@ -8,7 +8,6 @@ import org.koitharu.kotatsu.core.cache.MemoryContentCache
import org.koitharu.kotatsu.core.model.LocalMangaSource import org.koitharu.kotatsu.core.model.LocalMangaSource
import org.koitharu.kotatsu.core.model.MangaSourceInfo import org.koitharu.kotatsu.core.model.MangaSourceInfo
import org.koitharu.kotatsu.core.model.UnknownMangaSource import org.koitharu.kotatsu.core.model.UnknownMangaSource
import org.koitharu.kotatsu.core.network.MirrorSwitchInterceptor
import org.koitharu.kotatsu.core.parser.external.ExternalMangaRepository import org.koitharu.kotatsu.core.parser.external.ExternalMangaRepository
import org.koitharu.kotatsu.core.parser.external.ExternalMangaSource import org.koitharu.kotatsu.core.parser.external.ExternalMangaSource
import org.koitharu.kotatsu.local.data.LocalMangaRepository import org.koitharu.kotatsu.local.data.LocalMangaRepository
@ -25,7 +24,6 @@ import org.koitharu.kotatsu.parsers.model.SortOrder
import java.lang.ref.WeakReference import java.lang.ref.WeakReference
import javax.inject.Inject import javax.inject.Inject
import javax.inject.Singleton import javax.inject.Singleton
import kotlin.collections.set
interface MangaRepository { interface MangaRepository {
@ -60,7 +58,7 @@ interface MangaRepository {
private val localMangaRepository: LocalMangaRepository, private val localMangaRepository: LocalMangaRepository,
private val loaderContext: MangaLoaderContext, private val loaderContext: MangaLoaderContext,
private val contentCache: MemoryContentCache, private val contentCache: MemoryContentCache,
private val mirrorSwitchInterceptor: MirrorSwitchInterceptor, private val mirrorSwitcher: MirrorSwitcher,
) { ) {
private val cache = ArrayMap<MangaSource, WeakReference<MangaRepository>>() private val cache = ArrayMap<MangaSource, WeakReference<MangaRepository>>()
@ -89,7 +87,7 @@ interface MangaRepository {
is MangaParserSource -> ParserMangaRepository( is MangaParserSource -> ParserMangaRepository(
parser = MangaParser(source, loaderContext), parser = MangaParser(source, loaderContext),
cache = contentCache, cache = contentCache,
mirrorSwitchInterceptor = mirrorSwitchInterceptor, mirrorSwitcher = mirrorSwitcher,
) )
is ExternalMangaSource -> if (source.isAvailable(context)) { is ExternalMangaSource -> if (source.isAvailable(context)) {

@ -0,0 +1,109 @@
package org.koitharu.kotatsu.core.parser
import android.util.Log
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import okhttp3.OkHttpClient
import okhttp3.Request
import org.koitharu.kotatsu.BuildConfig
import org.koitharu.kotatsu.core.network.MangaHttpClient
import org.koitharu.kotatsu.core.prefs.AppSettings
import org.koitharu.kotatsu.parsers.model.MangaParserSource
import org.koitharu.kotatsu.parsers.util.await
import org.koitharu.kotatsu.parsers.util.runCatchingCancellable
import java.util.EnumSet
import javax.inject.Inject
class MirrorSwitcher @Inject constructor(
private val settings: AppSettings,
@MangaHttpClient private val okHttpClient: OkHttpClient,
) {
private val blacklist = EnumSet.noneOf(MangaParserSource::class.java)
private val mutex: Mutex = Mutex()
val isEnabled: Boolean
get() = settings.isMirrorSwitchingEnabled
suspend fun <T : Any> trySwitchMirror(repository: ParserMangaRepository, loader: suspend () -> T?): T? {
val source = repository.source
if (!isEnabled || source in blacklist) {
return null
}
val availableMirrors = repository.domains
val currentHost = repository.domain
if (availableMirrors.size <= 1 || currentHost !in availableMirrors) {
return null
}
mutex.withLock {
if (source in blacklist) {
return null
}
logd { "Looking for mirrors for ${source}..." }
findRedirect(repository)?.let { mirror ->
repository.domain = mirror
runCatchingCancellable {
loader()?.takeIfValid()
}.getOrNull()?.let {
logd { "Found redirect for $source: $mirror" }
return it
}
}
for (mirror in availableMirrors) {
repository.domain = mirror
runCatchingCancellable {
loader()?.takeIfValid()
}.getOrNull()?.let {
logd { "Found mirror for $source: $mirror" }
return it
}
}
repository.domain = currentHost // rollback
blacklist.add(source)
logd { "$source blacklisted" }
return null
}
}
suspend fun findRedirect(repository: ParserMangaRepository): String? {
if (!isEnabled) {
return null
}
val currentHost = repository.domain
val newHost = okHttpClient.newCall(
Request.Builder()
.url("https://$currentHost")
.head()
.build(),
).await().use {
if (it.isSuccessful) {
it.request.url.host
} else {
null
}
}
return if (newHost != currentHost) {
newHost
} else {
null
}
}
private fun <T : Any> T.takeIfValid() = takeIf {
when (it) {
is Collection<*> -> it.isNotEmpty()
else -> true
}
}
private companion object {
const val TAG = "MirrorSwitcher"
inline fun logd(message: () -> String) {
if (BuildConfig.DEBUG) {
Log.d(TAG, message())
}
}
}
}

@ -4,11 +4,14 @@ import kotlinx.coroutines.Dispatchers
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.Response import okhttp3.Response
import org.koitharu.kotatsu.core.cache.MemoryContentCache import org.koitharu.kotatsu.core.cache.MemoryContentCache
import org.koitharu.kotatsu.core.network.MirrorSwitchInterceptor import org.koitharu.kotatsu.core.exceptions.CloudFlareProtectedException
import org.koitharu.kotatsu.core.exceptions.InteractiveActionRequiredException
import org.koitharu.kotatsu.core.exceptions.ProxyConfigException
import org.koitharu.kotatsu.core.prefs.SourceSettings import org.koitharu.kotatsu.core.prefs.SourceSettings
import org.koitharu.kotatsu.parsers.MangaParser import org.koitharu.kotatsu.parsers.MangaParser
import org.koitharu.kotatsu.parsers.MangaParserAuthProvider import org.koitharu.kotatsu.parsers.MangaParserAuthProvider
import org.koitharu.kotatsu.parsers.config.ConfigKey import org.koitharu.kotatsu.parsers.config.ConfigKey
import org.koitharu.kotatsu.parsers.exception.AuthRequiredException
import org.koitharu.kotatsu.parsers.model.Favicons import org.koitharu.kotatsu.parsers.model.Favicons
import org.koitharu.kotatsu.parsers.model.Manga import org.koitharu.kotatsu.parsers.model.Manga
import org.koitharu.kotatsu.parsers.model.MangaChapter import org.koitharu.kotatsu.parsers.model.MangaChapter
@ -23,12 +26,12 @@ import org.koitharu.kotatsu.parsers.util.suspendlazy.suspendLazy
class ParserMangaRepository( class ParserMangaRepository(
private val parser: MangaParser, private val parser: MangaParser,
private val mirrorSwitchInterceptor: MirrorSwitchInterceptor, private val mirrorSwitcher: MirrorSwitcher,
cache: MemoryContentCache, cache: MemoryContentCache,
) : CachingMangaRepository(cache), Interceptor { ) : CachingMangaRepository(cache), Interceptor {
private val filterOptionsLazy = suspendLazy(Dispatchers.Default) { private val filterOptionsLazy = suspendLazy(Dispatchers.Default) {
mirrorSwitchInterceptor.withMirrorSwitching { withMirrors {
parser.getFilterOptions() parser.getFilterOptions()
} }
} }
@ -60,18 +63,18 @@ class ParserMangaRepository(
override fun intercept(chain: Interceptor.Chain): Response = parser.intercept(chain) override fun intercept(chain: Interceptor.Chain): Response = parser.intercept(chain)
override suspend fun getList(offset: Int, order: SortOrder?, filter: MangaListFilter?): List<Manga> { override suspend fun getList(offset: Int, order: SortOrder?, filter: MangaListFilter?): List<Manga> {
return mirrorSwitchInterceptor.withMirrorSwitching { return withMirrors {
parser.getList(offset, order ?: defaultSortOrder, filter ?: MangaListFilter.EMPTY) parser.getList(offset, order ?: defaultSortOrder, filter ?: MangaListFilter.EMPTY)
} }
} }
override suspend fun getPagesImpl( override suspend fun getPagesImpl(
chapter: MangaChapter chapter: MangaChapter
): List<MangaPage> = mirrorSwitchInterceptor.withMirrorSwitching { ): List<MangaPage> = withMirrors {
parser.getPages(chapter) parser.getPages(chapter)
} }
override suspend fun getPageUrl(page: MangaPage): String = mirrorSwitchInterceptor.withMirrorSwitching { override suspend fun getPageUrl(page: MangaPage): String = withMirrors {
parser.getPageUrl(page).also { result -> parser.getPageUrl(page).also { result ->
check(result.isNotEmpty()) { "Page url is empty" } check(result.isNotEmpty()) { "Page url is empty" }
} }
@ -79,13 +82,13 @@ class ParserMangaRepository(
override suspend fun getFilterOptions(): MangaListFilterOptions = filterOptionsLazy.get() override suspend fun getFilterOptions(): MangaListFilterOptions = filterOptionsLazy.get()
suspend fun getFavicons(): Favicons = mirrorSwitchInterceptor.withMirrorSwitching { suspend fun getFavicons(): Favicons = withMirrors {
parser.getFavicons() parser.getFavicons()
} }
override suspend fun getRelatedMangaImpl(seed: Manga): List<Manga> = parser.getRelatedManga(seed) override suspend fun getRelatedMangaImpl(seed: Manga): List<Manga> = parser.getRelatedManga(seed)
override suspend fun getDetailsImpl(manga: Manga): Manga = mirrorSwitchInterceptor.withMirrorSwitching { override suspend fun getDetailsImpl(manga: Manga): Manga = withMirrors {
parser.getDetails(manga) parser.getDetails(manga)
} }
@ -107,31 +110,34 @@ class ParserMangaRepository(
fun getConfig() = parser.config as SourceSettings fun getConfig() = parser.config as SourceSettings
private suspend fun <R> MirrorSwitchInterceptor.withMirrorSwitching(block: suspend () -> R): R { private suspend fun <T : Any> withMirrors(block: suspend () -> T): T {
if (!isEnabled) { if (!mirrorSwitcher.isEnabled) {
return block() return block()
} }
val initialMirror = domain val initialResult = runCatchingCancellable { block() }
val result = runCatchingCancellable { if (initialResult.isValidResult()) {
block() return initialResult.getOrThrow()
}
if (result.isValidResult()) {
return result.getOrThrow()
}
return if (trySwitchMirror(this@ParserMangaRepository)) {
val newResult = runCatchingCancellable {
block()
}
if (newResult.isValidResult()) {
return newResult.getOrThrow()
} else {
rollback(this@ParserMangaRepository, initialMirror)
return result.getOrThrow()
}
} else {
result.getOrThrow()
} }
val newResult = mirrorSwitcher.trySwitchMirror(this, block)
return newResult ?: initialResult.getOrThrow()
} }
private fun Result<*>.isValidResult() = isSuccess && (getOrNull() as? Collection<*>)?.isEmpty() != true private fun Result<Any>.isValidResult() = fold(
onSuccess = {
when (it) {
is Collection<*> -> it.isNotEmpty()
else -> true
}
},
onFailure = {
when (it.cause) {
is CloudFlareProtectedException,
is AuthRequiredException,
is InteractiveActionRequiredException,
is ProxyConfigException -> true
else -> false
}
},
)
} }

@ -263,7 +263,7 @@ class AppSettings @Inject constructor(@ApplicationContext context: Context) {
get() = prefs.getBoolean(KEY_PROTECT_APP_BIOMETRIC, true) get() = prefs.getBoolean(KEY_PROTECT_APP_BIOMETRIC, true)
set(value) = prefs.edit { putBoolean(KEY_PROTECT_APP_BIOMETRIC, value) } set(value) = prefs.edit { putBoolean(KEY_PROTECT_APP_BIOMETRIC, value) }
val isMirrorSwitchingAvailable: Boolean val isMirrorSwitchingEnabled: Boolean
get() = prefs.getBoolean(KEY_MIRROR_SWITCHING, false) get() = prefs.getBoolean(KEY_MIRROR_SWITCHING, false)
val isExitConfirmationEnabled: Boolean val isExitConfirmationEnabled: Boolean

Loading…
Cancel
Save