/* * Copyright 2019 tom5079 * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ package xyz.quaver.pupil.hitomi import kotlinx.coroutines.* import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock import kotlinx.datetime.Clock.System.now import kotlinx.serialization.Serializable import kotlinx.serialization.decodeFromString import kotlinx.serialization.json.Json import okhttp3.Call import okhttp3.Callback import okhttp3.Request import okhttp3.Response import xyz.quaver.pupil.client import java.io.IOException import java.net.URL import java.util.concurrent.Executors import kotlin.coroutines.resumeWithException import kotlin.time.Duration.Companion.minutes import kotlin.time.ExperimentalTime const val protocol = "https:" @Serializable data class Artist( val artist: String, val url: String ) @Serializable data class Group( val group: String, val url: String ) @Serializable data class Parody( val parody: String, val url: String ) @Serializable data class Character( val character: String, val url: String ) @Serializable data class Tag( val tag: String, val url: String, val female: String? = null, val male: String? = null ) @Serializable data class Language( val galleryid: String, val url: String, val language_localname: String, val name: String ) @Serializable data class GalleryInfo( val id: String, val title: String, val japanese_title: String? = null, val language: String? = null, val type: String, val date: String, val artists: List? = null, val groups: List? = null, val parodys: List? = null, val tags: List? = null, val related: List = emptyList(), val languages: List = emptyList(), val characters: List? = null, val scene_indexes: List? = emptyList(), val files: List = emptyList() ) val json = Json { isLenient = true ignoreUnknownKeys = true allowSpecialFloatingPointValues = true useArrayPolymorphism = true } typealias HeaderSetter = (Request.Builder) -> Request.Builder fun URL.readText(settings: HeaderSetter? = null): String { val request = Request.Builder() .url(this).let { settings?.invoke(it) ?: it }.build() return client.newCall(request).execute().also{ if (it.code() != 200) throw IOException("CODE ${it.code()}") }.body()?.use { it.string() } ?: throw IOException() } fun URL.readBytes(settings: HeaderSetter? = null): ByteArray { val request = Request.Builder() .url(this).let { settings?.invoke(it) ?: it }.build() return client.newCall(request).execute().also { if (it.code() != 200) throw IOException("CODE ${it.code()}") }.body()?.use { it.bytes() } ?: throw IOException() } @Suppress("EXPERIMENTAL_API_USAGE") fun getGalleryInfo(galleryID: Int) = json.decodeFromString( URL("$protocol//$domain/galleries/$galleryID.js").readText() .replace("var galleryinfo = ", "") ) //common.js const val domain = "ltn.hitomi.la" const val galleryblockextension = ".html" const val galleryblockdir = "galleryblock" const val nozomiextension = ".nozomi" val evaluationContext = Dispatchers.Main + Job() object gg { private var lastRetrieval: Long? = null private val mutex = Mutex() private var mDefault = 0 private val mMap = mutableMapOf() private var b = "" @OptIn(ExperimentalTime::class, ExperimentalCoroutinesApi::class) private suspend fun refresh() = withContext(Dispatchers.IO) { mutex.withLock { if (lastRetrieval == null || (lastRetrieval!! + 60000) < System.currentTimeMillis()) { val ggjs: String = suspendCancellableCoroutine { continuation -> val call = client.newCall(Request.Builder().url("https://ltn.hitomi.la/gg.js").build()) call.enqueue(object: Callback { override fun onFailure(call: Call, e: IOException) { if (continuation.isCancelled) return continuation.resumeWithException(e) } override fun onResponse(call: Call, response: Response) { if (!call.isCanceled) { response.body()?.use { continuation.resume(it.string()) { call.cancel() } } } } }) continuation.invokeOnCancellation { call.cancel() } } mDefault = Regex("var o = (\\d)").find(ggjs)!!.groupValues[1].toInt() val o = Regex("o = (\\d); break;").find(ggjs)!!.groupValues[1].toInt() mMap.clear() Regex("case (\\d+):").findAll(ggjs).forEach { val case = it.groupValues[1].toInt() mMap[case] = o } b = Regex("b: '(.+)'").find(ggjs)!!.groupValues[1] lastRetrieval = System.currentTimeMillis() } } } suspend fun m(g: Int): Int { refresh() return mMap[g] ?: mDefault } suspend fun b(): String { refresh() return b } fun s(h: String): String { val m = Regex("(..)(.)$").find(h) return m!!.groupValues.let { it[2]+it[1] }.toInt(16).toString(10) } } suspend fun subdomainFromURL(url: String, base: String? = null) : String { var retval = "b" if (!base.isNullOrBlank()) retval = base val b = 16 val r = Regex("""/[0-9a-f]{61}([0-9a-f]{2})([0-9a-f])""") val m = r.find(url) ?: return "a" val g = m.groupValues.let { it[2]+it[1] }.toIntOrNull(b) if (g != null) { retval = (97+ gg.m(g)).toChar().toString() + retval } return retval } suspend fun urlFromUrl(url: String, base: String? = null) : String { return url.replace(Regex("""//..?\.hitomi\.la/"""), "//${subdomainFromURL(url, base)}.hitomi.la/") } suspend fun fullPathFromHash(hash: String) : String = "${gg.b()}${gg.s(hash)}/$hash" fun realFullPathFromHash(hash: String): String = hash.replace(Regex("""^.*(..)(.)$"""), "$2/$1/$hash") suspend fun urlFromHash(galleryID: Int, image: GalleryFiles, dir: String? = null, ext: String? = null) : String { val ext = ext ?: dir ?: image.name.takeLastWhile { it != '.' } val dir = dir ?: "images" return "https://a.hitomi.la/$dir/${fullPathFromHash(image.hash)}.$ext" } suspend fun urlFromUrlFromHash(galleryID: Int, image: GalleryFiles, dir: String? = null, ext: String? = null, base: String? = null) = if (base == "tn") urlFromUrl("https://a.hitomi.la/$dir/${realFullPathFromHash(image.hash)}.$ext", base) else urlFromUrl(urlFromHash(galleryID, image, dir, ext), base) suspend fun rewriteTnPaths(html: String) { html.replace(Regex("""//tn\.hitomi\.la/[^/]+/[0-9a-f]/[0-9a-f]{2}/[0-9a-f]{64}""")) { url -> runBlocking { urlFromUrl(url.value, "tn") } } } suspend fun imageUrlFromImage(galleryID: Int, image: GalleryFiles, noWebp: Boolean) : String { return urlFromUrlFromHash(galleryID, image, "webp", null, "a") // return when { // noWebp -> // urlFromUrlFromHash(galleryID, image) //// image.hasavif != 0 -> //// urlFromUrlFromHash(galleryID, image, "avif", null, "a") // image.haswebp != 0 -> // urlFromUrlFromHash(galleryID, image, "webp", null, "a") // else -> // urlFromUrlFromHash(galleryID, image) // } }