Hitomi: change cdn domain & fix image url logic (#8204)

* Hitomi: change cdn domain & image url logic

* remove unused

* avifbigtn
This commit is contained in:
AwkwardPeak7 2025-03-25 09:20:59 +05:00 committed by Draff
parent ea28acd641
commit 77bd833e6a
No known key found for this signature in database
GPG Key ID: E8A89F3211677653
3 changed files with 32 additions and 108 deletions

View File

@ -1,7 +1,7 @@
ext { ext {
extName = 'Hitomi' extName = 'Hitomi'
extClass = '.HitomiFactory' extClass = '.HitomiFactory'
extVersionCode = 37 extVersionCode = 38
isNsfw = true isNsfw = true
} }

View File

@ -1,12 +1,8 @@
package eu.kanade.tachiyomi.extension.all.hitomi package eu.kanade.tachiyomi.extension.all.hitomi
import android.content.SharedPreferences
import android.util.Log import android.util.Log
import androidx.preference.ListPreference
import androidx.preference.PreferenceScreen
import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.await import eu.kanade.tachiyomi.network.await
import eu.kanade.tachiyomi.source.ConfigurableSource
import eu.kanade.tachiyomi.source.model.FilterList import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.model.MangasPage import eu.kanade.tachiyomi.source.model.MangasPage
import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.model.Page
@ -14,33 +10,28 @@ import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.model.UpdateStrategy import eu.kanade.tachiyomi.source.model.UpdateStrategy
import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.source.online.HttpSource
import keiyoushi.utils.getPreferencesLazy import keiyoushi.utils.parseAs
import keiyoushi.utils.tryParse
import kotlinx.coroutines.async import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.coroutineScope import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import okhttp3.CacheControl import okhttp3.CacheControl
import okhttp3.Call import okhttp3.Call
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Interceptor import okhttp3.Interceptor
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.Request import okhttp3.Request
import okhttp3.Response import okhttp3.Response
import okhttp3.ResponseBody.Companion.toResponseBody
import okhttp3.internal.http2.StreamResetException import okhttp3.internal.http2.StreamResetException
import rx.Observable import rx.Observable
import uy.kohesive.injekt.injectLazy
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.nio.ByteOrder import java.nio.ByteOrder
import java.security.MessageDigest import java.security.MessageDigest
import java.text.ParseException
import java.text.SimpleDateFormat import java.text.SimpleDateFormat
import java.util.LinkedList import java.util.LinkedList
import java.util.Locale import java.util.Locale
import kotlin.math.max
import kotlin.math.min import kotlin.math.min
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@ -48,33 +39,25 @@ import kotlin.time.Duration.Companion.seconds
class Hitomi( class Hitomi(
override val lang: String, override val lang: String,
private val nozomiLang: String, private val nozomiLang: String,
) : HttpSource(), ConfigurableSource { ) : HttpSource() {
override val name = "Hitomi" override val name = "Hitomi"
private val domain = "hitomi.la" private val cdnDomain = "gold-usergeneratedcontent.net"
override val baseUrl = "https://$domain" override val baseUrl = "https://hitomi.la"
private val ltnUrl = "https://ltn.$domain" private val ltnUrl = "https://ltn.$cdnDomain"
override val supportsLatest = true override val supportsLatest = true
private val json: Json by injectLazy()
private val REGEX_IMAGE_URL = """https://.*?a\.$domain/(jxl|avif|webp)/\d+?/\d+/([0-9a-f]{64})\.\1""".toRegex()
override val client = network.cloudflareClient.newBuilder() override val client = network.cloudflareClient.newBuilder()
.addInterceptor(::jxlContentTypeInterceptor)
.addInterceptor(::updateImageUrlInterceptor) .addInterceptor(::updateImageUrlInterceptor)
.apply { .apply {
interceptors().add(0, ::streamResetRetry) interceptors().add(0, ::streamResetRetry)
} }
.build() .build()
private val preferences: SharedPreferences by getPreferencesLazy()
private fun imageType() = preferences.getString(PREF_IMAGETYPE, "webp")!!
override fun headersBuilder() = super.headersBuilder() override fun headersBuilder() = super.headersBuilder()
.set("referer", "$baseUrl/") .set("referer", "$baseUrl/")
.set("origin", baseUrl) .set("origin", baseUrl)
@ -519,7 +502,7 @@ class Hitomi(
val imageId = imageIdFromHash(hash) val imageId = imageIdFromHash(hash)
val subDomain = 'a' + subdomainOffset(imageId) val subDomain = 'a' + subdomainOffset(imageId)
"https://${subDomain}tn.$domain/webpbigtn/${thumbPathFromHash(hash)}/$hash.webp" "https://${subDomain}tn.$cdnDomain/avifbigtn/${thumbPathFromHash(hash)}/$hash.avif"
} }
description = buildString { description = buildString {
japaneseTitle?.let { japaneseTitle?.let {
@ -564,11 +547,7 @@ class Hitomi(
name = "Chapter" name = "Chapter"
url = gallery.galleryurl url = gallery.galleryurl
scanlator = gallery.type scanlator = gallery.type
date_upload = try { date_upload = dateFormat.tryParse(gallery.date.substringBeforeLast("-"))
dateFormat.parse(gallery.date.substringBeforeLast("-"))!!.time
} catch (_: ParseException) {
0L
}
}, },
) )
} }
@ -585,28 +564,18 @@ class Hitomi(
return GET("$ltnUrl/galleries/$id.js", headers) return GET("$ltnUrl/galleries/$id.js", headers)
} }
override fun pageListParse(response: Response) = runBlocking { override fun pageListParse(response: Response): List<Page> {
val gallery = response.parseScriptAs<Gallery>() val gallery = response.parseScriptAs<Gallery>()
val id = gallery.galleryurl val id = gallery.galleryurl
.substringAfterLast("-") .substringAfterLast("-")
.substringBefore(".") .substringBefore(".")
gallery.files.mapIndexed { idx, img -> return gallery.files.mapIndexed { idx, img ->
val hash = img.hash // actual logic in updateImageUrlInterceptor
val imageUrl = "http://127.0.0.1".toHttpUrl().newBuilder()
val typePref = imageType() .fragment(img.hash)
val avif = img.hasavif == 1 && typePref == "avif" .build()
val jxl = img.hasjxl == 1 && typePref == "jxl" .toString()
val commonId = commonImageId()
val imageId = imageIdFromHash(hash)
val subDomain = 'a' + subdomainOffset(imageId)
val imageUrl = when {
jxl -> "https://${subDomain}a.$domain/jxl/$commonId$imageId/$hash.jxl"
avif -> "https://${subDomain}a.$domain/avif/$commonId$imageId/$hash.avif"
else -> "https://${subDomain}a.$domain/webp/$commonId$imageId/$hash.webp"
}
Page( Page(
idx, idx,
@ -632,7 +601,7 @@ class Hitomi(
val body = use { it.body.string() } val body = use { it.body.string() }
val transformed = transform(body) val transformed = transform(body)
return json.decodeFromString(transformed) return transformed.parseAs()
} }
private suspend fun Call.awaitSuccess() = private suspend fun Call.awaitSuccess() =
@ -694,45 +663,6 @@ class Hitomi(
return hash.replace(Regex("""^.*(..)(.)$"""), "$2/$1") return hash.replace(Regex("""^.*(..)(.)$"""), "$2/$1")
} }
override fun setupPreferenceScreen(screen: PreferenceScreen) {
ListPreference(screen.context).apply {
key = PREF_IMAGETYPE
title = "Images Type"
entries = arrayOf("webp", "avif", "jxl")
entryValues = arrayOf("webp", "avif", "jxl")
summary = "Clear chapter cache to apply changes"
setDefaultValue("webp")
}.also(screen::addPreference)
}
private fun List<Int>.toBytesList(): ByteArray = this.map { it.toByte() }.toByteArray()
private val signatureOne = listOf(0xFF, 0x0A).toBytesList()
private val signatureTwo = listOf(0x00, 0x00, 0x00, 0x0C, 0x4A, 0x58, 0x4C, 0x20, 0x0D, 0x0A, 0x87, 0x0A).toBytesList()
fun ByteArray.startsWith(byteArray: ByteArray): Boolean {
if (this.size < byteArray.size) return false
return this.sliceArray(byteArray.indices).contentEquals(byteArray)
}
private fun jxlContentTypeInterceptor(chain: Interceptor.Chain): Response {
val response = chain.proceed(chain.request())
if (response.headers["Content-Type"] != "application/octet-stream") {
return response
}
val bytesPeek = max(signatureOne.size, signatureTwo.size).toLong()
val bytesArray = response.peekBody(bytesPeek).bytes()
if (!(bytesArray.startsWith(signatureOne) || bytesArray.startsWith(signatureTwo))) {
return response
}
val type = "image/jxl"
val body = response.body.bytes().toResponseBody(type.toMediaType())
return response.newBuilder()
.body(body)
.header("Content-Type", type)
.build()
}
private fun streamResetRetry(chain: Interceptor.Chain): Response { private fun streamResetRetry(chain: Interceptor.Chain): Response {
return try { return try {
chain.proceed(chain.request()) chain.proceed(chain.request())
@ -749,21 +679,22 @@ class Hitomi(
private fun updateImageUrlInterceptor(chain: Interceptor.Chain): Response { private fun updateImageUrlInterceptor(chain: Interceptor.Chain): Response {
val request = chain.request() val request = chain.request()
if (request.url.host != "127.0.0.1") {
val cleanUrl = request.url.run { "$scheme://$host$encodedPath" } return chain.proceed(request)
REGEX_IMAGE_URL.matchEntire(cleanUrl)?.let { match ->
val (ext, hash) = match.destructured
val commonId = runBlocking { commonImageId() }
val imageId = imageIdFromHash(hash)
val subDomain = 'a' + runBlocking { subdomainOffset(imageId) }
val newUrl = "https://${subDomain}a.$domain/$ext/$commonId$imageId/$hash.$ext"
val newRequest = request.newBuilder().url(newUrl).build()
return chain.proceed(newRequest)
} }
return chain.proceed(request) val hash = request.url.fragment!!
val commonId = runBlocking { commonImageId() }
val imageId = imageIdFromHash(hash)
val subDomain = runBlocking { (subdomainOffset(imageId) + 1) }
val imageUrl = "https://a$subDomain.$cdnDomain/$commonId$imageId/$hash.avif"
val newRequest = request.newBuilder()
.url(imageUrl)
.build()
return chain.proceed(newRequest)
} }
override fun popularMangaParse(response: Response) = throw UnsupportedOperationException() override fun popularMangaParse(response: Response) = throw UnsupportedOperationException()
@ -773,8 +704,4 @@ class Hitomi(
override fun searchMangaRequest(page: Int, query: String, filters: FilterList) = throw UnsupportedOperationException() override fun searchMangaRequest(page: Int, query: String, filters: FilterList) = throw UnsupportedOperationException()
override fun searchMangaParse(response: Response) = throw UnsupportedOperationException() override fun searchMangaParse(response: Response) = throw UnsupportedOperationException()
override fun imageUrlParse(response: Response) = throw UnsupportedOperationException() override fun imageUrlParse(response: Response) = throw UnsupportedOperationException()
companion object {
const val PREF_IMAGETYPE = "pref_image_type"
}
} }

View File

@ -22,9 +22,6 @@ class Gallery(
@Serializable @Serializable
class ImageFile( class ImageFile(
val hash: String, val hash: String,
val haswebp: Int?,
val hasavif: Int?,
val hasjxl: Int?,
) )
@Serializable @Serializable