Yeet Hitomi (#11613)

This commit is contained in:
FourTOne5 2022-04-25 15:55:48 -07:00 committed by GitHub
parent 7113c5166a
commit 40fab1d9b5
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
14 changed files with 0 additions and 1007 deletions

View File

@ -1,36 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="eu.kanade.tachiyomi.extension">
<application>
<activity
android:name=".all.hitomi.HitomiActivity"
android:excludeFromRecents="true"
android:exported="true"
android:theme="@android:style/Theme.NoDisplay">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="hitomi.la"
android:pathPattern="/manga/..*"
android:scheme="https" />
<data
android:host="hitomi.la"
android:pathPattern="/doujinshi/..*"
android:scheme="https" />
<data
android:host="hitomi.la"
android:pathPattern="/cg/..*"
android:scheme="https" />
<data
android:host="hitomi.la"
android:pathPattern="/gamecg/..*"
android:scheme="https" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@ -1,13 +0,0 @@
apply plugin: 'com.android.application'
apply plugin: 'kotlin-android'
apply plugin: 'kotlinx-serialization'
ext {
extName = 'Hitomi.la'
pkgNameSuffix = 'all.hitomi'
extClass = '.HitomiFactory'
extVersionCode = 16
isNsfw = true
}
apply from: "$rootDir/common.gradle"

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.8 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 56 KiB

View File

@ -1,93 +0,0 @@
package eu.kanade.tachiyomi.extension.all.hitomi
import java.nio.ByteBuffer
/**
* Simple cursor for use on byte arrays
* @author nulldev
*/
class ByteCursor(val content: ByteArray) {
var index = -1
private set
private var mark = -1
fun mark() {
mark = index
}
fun jumpToMark() {
index = mark
}
fun jumpToIndex(index: Int) {
this.index = index
}
fun next(): Byte {
return content[++index]
}
fun next(count: Int): ByteArray {
val res = content.sliceArray(index + 1..index + count)
skip(count)
return res
}
// Used to perform conversions
private fun byteBuffer(count: Int): ByteBuffer {
return ByteBuffer.wrap(next(count))
}
// Epic hack to get an unsigned short properly...
fun fakeNextShortInt(): Int = ByteBuffer
.wrap(arrayOf(0x00, 0x00, *next(2).toTypedArray()).toByteArray())
.getInt(0)
// fun nextShort(): Short = byteBuffer(2).getShort(0)
fun nextInt(): Int = byteBuffer(4).getInt(0)
fun nextLong(): Long = byteBuffer(8).getLong(0)
fun nextFloat(): Float = byteBuffer(4).getFloat(0)
fun nextDouble(): Double = byteBuffer(8).getDouble(0)
fun skip(count: Int) {
index += count
}
fun expect(vararg bytes: Byte) {
if (bytes.size > remaining()) {
throw IllegalStateException("Unexpected end of content!")
}
for (i in 0..bytes.lastIndex) {
val expected = bytes[i]
val actual = content[index + i + 1]
if (expected != actual) {
throw IllegalStateException("Unexpected content (expected: $expected, actual: $actual)!")
}
}
index += bytes.size
}
fun checkEqual(vararg bytes: Byte): Boolean {
if (bytes.size > remaining()) {
return false
}
for (i in 0..bytes.lastIndex) {
val expected = bytes[i]
val actual = content[index + i + 1]
if (expected != actual) {
return false
}
}
return true
}
fun atEnd() = index >= content.size - 1
fun remaining() = content.size - index - 1
}

View File

@ -1,504 +0,0 @@
package eu.kanade.tachiyomi.extension.all.hitomi
import android.app.Application
import android.content.SharedPreferences
import com.squareup.duktape.Duktape
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.asObservableSuccess
import eu.kanade.tachiyomi.source.ConfigurableSource
import eu.kanade.tachiyomi.source.model.Filter
import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.model.MangasPage
import eu.kanade.tachiyomi.source.model.Page
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.util.asJsoup
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.json.Json
import okhttp3.Request
import okhttp3.Response
import rx.Observable
import rx.Single
import rx.schedulers.Schedulers
import uy.kohesive.injekt.Injekt
import uy.kohesive.injekt.api.get
import uy.kohesive.injekt.injectLazy
import java.net.URLEncoder
import java.util.Locale
import androidx.preference.CheckBoxPreference as AndroidXCheckBoxPreference
import androidx.preference.PreferenceScreen as AndroidXPreferenceScreen
/**
* Ported from TachiyomiSy
* Original work by NerdNumber9 for TachiyomiEH
*/
open class Hitomi(override val lang: String, private val nozomiLang: String) : HttpSource(), ConfigurableSource {
override val supportsLatest = true
override val name = if (nozomiLang == "all") "Hitomi.la unfiltered" else "Hitomi.la"
override val id by lazy { if (lang == "all") 2703068117101782422 else super.id }
override val baseUrl = BASE_URL
private val json: Json by injectLazy()
private var gg: String? = null
// Popular
override fun fetchPopularManga(page: Int): Observable<MangasPage> {
return client.newCall(popularMangaRequest(page))
.asObservableSuccess()
.flatMap { responseToMangas(it) }
}
override fun popularMangaRequest(page: Int) = HitomiNozomi.rangedGet(
"$LTN_BASE_URL/popular-$nozomiLang.nozomi",
100L * (page - 1),
99L + 100 * (page - 1)
)
private fun responseToMangas(response: Response): Observable<MangasPage> {
val range = response.header("Content-Range")!!
val total = range.substringAfter('/').toLong()
val end = range.substringBefore('/').substringAfter('-').toLong()
val body = response.body!!
return parseNozomiPage(body.bytes())
.map {
MangasPage(it, end < total - 1)
}
}
private fun parseNozomiPage(array: ByteArray): Observable<List<SManga>> {
val cursor = ByteCursor(array)
val ids = (1..array.size / 4).map {
cursor.nextInt()
}
return nozomiIdsToMangas(ids).toObservable()
}
private fun nozomiIdsToMangas(ids: List<Int>): Single<List<SManga>> {
return Single.zip(
ids.map { int ->
client.newCall(GET("$LTN_BASE_URL/galleryblock/$int.html"))
.asObservableSuccess()
.subscribeOn(Schedulers.io()) // Perform all these requests in parallel
.map { parseGalleryBlock(it) }
.toSingle()
}
) { it.map { m -> m as SManga } }
}
private fun parseGalleryBlock(response: Response): SManga {
val doc = response.asJsoup()
return SManga.create().apply {
val titleElement = doc.selectFirst("h1")
title = titleElement.text()
thumbnail_url = "https:" + if (useHqThumbPref()) {
doc.selectFirst("img").attr("srcset").substringBefore(' ')
} else {
doc.selectFirst("img").attr("src")
}
url = titleElement.child(0).attr("href")
}
}
override fun popularMangaParse(response: Response) = throw UnsupportedOperationException("Not used")
// Latest
override fun fetchLatestUpdates(page: Int): Observable<MangasPage> {
return client.newCall(latestUpdatesRequest(page))
.asObservableSuccess()
.flatMap { responseToMangas(it) }
}
override fun latestUpdatesRequest(page: Int) = HitomiNozomi.rangedGet(
"$LTN_BASE_URL/index-$nozomiLang.nozomi",
100L * (page - 1),
99L + 100 * (page - 1)
)
override fun latestUpdatesParse(response: Response) = throw UnsupportedOperationException("Not used")
// Search
private var cachedTagIndexVersion: Long? = null
private var tagIndexVersionCacheTime: Long = 0
private fun tagIndexVersion(): Single<Long> {
val sCachedTagIndexVersion = cachedTagIndexVersion
return if (sCachedTagIndexVersion == null ||
tagIndexVersionCacheTime + INDEX_VERSION_CACHE_TIME_MS < System.currentTimeMillis()
) {
HitomiNozomi.getIndexVersion(client, "tagindex").subscribeOn(Schedulers.io()).doOnNext {
cachedTagIndexVersion = it
tagIndexVersionCacheTime = System.currentTimeMillis()
}.toSingle()
} else {
Single.just(sCachedTagIndexVersion)
}
}
private var cachedGalleryIndexVersion: Long? = null
private var galleryIndexVersionCacheTime: Long = 0
private fun galleryIndexVersion(): Single<Long> {
val sCachedGalleryIndexVersion = cachedGalleryIndexVersion
return if (sCachedGalleryIndexVersion == null ||
galleryIndexVersionCacheTime + INDEX_VERSION_CACHE_TIME_MS < System.currentTimeMillis()
) {
HitomiNozomi.getIndexVersion(client, "galleriesindex").subscribeOn(Schedulers.io()).doOnNext {
cachedGalleryIndexVersion = it
galleryIndexVersionCacheTime = System.currentTimeMillis()
}.toSingle()
} else {
Single.just(sCachedGalleryIndexVersion)
}
}
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
return if (query.startsWith(PREFIX_ID_SEARCH)) {
val id = NOZOMI_ID_PATTERN.find(query.removePrefix(PREFIX_ID_SEARCH))!!.value.toInt()
nozomiIdsToMangas(listOf(id)).map { mangas ->
MangasPage(mangas, false)
}.toObservable()
} else {
if (query.isBlank()) {
val area = filters.filterIsInstance<TypeFilter>()
.joinToString("") {
(it as UriPartFilter).toUriPart()
}
val keyword = filters.filterIsInstance<Text>().toString()
.replace("[", "").replace("]", "")
val popular = filters.filterIsInstance<SortFilter>()
.joinToString("") {
(it as UriPartFilter).toUriPart()
} == "true"
// TODO Cache the results coming out of HitomiNozomi (this TODO dates back to TachiyomiEH)
val hn = Single.zip(tagIndexVersion(), galleryIndexVersion()) { tv, gv -> tv to gv }
.map { HitomiNozomi(client, it.first, it.second) }
val base = hn.flatMap { n ->
n.getGalleryIdsForQuery("$area:${URLEncoder.encode(keyword, "utf-8")}", nozomiLang, popular).map { n to it.toSet() }
}
base.flatMap { (_, ids) ->
val chunks = ids.chunked(PAGE_SIZE)
nozomiIdsToMangas(chunks[page - 1]).map { mangas ->
MangasPage(mangas, page < chunks.size)
}
}.toObservable()
} else {
val splitQuery = query.toLowerCase(Locale.ENGLISH).split(" ")
val positive = splitQuery.filter {
COMMON_WORDS.any { word ->
it !== word
} && !it.startsWith('-')
}.toMutableList()
if (nozomiLang != "all") positive += "language:$nozomiLang"
val negative = (splitQuery - positive).map { it.removePrefix("-") }
// TODO Cache the results coming out of HitomiNozomi (this TODO dates back to TachiyomiEH)
val hn = Single.zip(tagIndexVersion(), galleryIndexVersion()) { tv, gv -> tv to gv }
.map { HitomiNozomi(client, it.first, it.second) }
var base = if (positive.isEmpty()) {
hn.flatMap { n ->
n.getGalleryIdsFromNozomi(null, "index", "all", false)
.map { n to it.toSet() }
}
} else {
val q = positive.removeAt(0)
hn.flatMap { n -> n.getGalleryIdsForQuery(q, nozomiLang, false).map { n to it.toSet() } }
}
base = positive.fold(base) { acc, q ->
acc.flatMap { (nozomi, mangas) ->
nozomi.getGalleryIdsForQuery(q, nozomiLang, false).map {
nozomi to mangas.intersect(it)
}
}
}
base = negative.fold(base) { acc, q ->
acc.flatMap { (nozomi, mangas) ->
nozomi.getGalleryIdsForQuery(q, nozomiLang, false).map {
nozomi to (mangas - it)
}
}
}
base.flatMap { (_, ids) ->
val chunks = ids.chunked(PAGE_SIZE)
nozomiIdsToMangas(chunks[page - 1]).map { mangas ->
MangasPage(mangas, page < chunks.size)
}
}.toObservable()
}
}
}
override fun searchMangaRequest(page: Int, query: String, filters: FilterList) = throw UnsupportedOperationException("Not used")
override fun searchMangaParse(response: Response) = throw UnsupportedOperationException("Not used")
// Filter
override fun getFilterList() = FilterList(
Filter.Header(Filter_SEARCH_MESSAGE),
Filter.Separator(),
SortFilter(),
TypeFilter(),
Text("Keyword")
)
private class TypeFilter : UriPartFilter(
"category",
Array(FILTER_CATEGORIES.size) { i ->
val category = FILTER_CATEGORIES[i]
Pair(category, category)
}
)
private class SortFilter : UriPartFilter(
"Ordered by",
arrayOf(
Pair("Date Added", "false"),
Pair("Popularity", "true")
)
)
private open class UriPartFilter(
displayName: String,
val pair: Array<Pair<String, String>>,
defaultState: Int = 0
) : Filter.Select<String>(displayName, pair.map { it.first }.toTypedArray(), defaultState) {
open fun toUriPart() = pair[state].second
}
private class Text(name: String) : Filter.Text(name) {
override fun toString(): String {
return state
}
}
// Details
override fun mangaDetailsParse(response: Response): SManga {
val document = response.asJsoup()
fun String.replaceSpaces() = this.replace(" ", "_")
return SManga.create().apply {
title = document.select("div.gallery h1 a").joinToString { it.text() }
thumbnail_url = document.select("div.cover img").attr("abs:src")
author = document.select("div.gallery h2 a").joinToString { it.text() }
val tableInfo = document.select("table tr")
.map { tr ->
val key = tr.select("td:first-child").text()
val value = with(tr.select("td:last-child a")) {
when (key) {
"Series", "Characters" -> {
if (text().isNotEmpty())
joinToString { "${attr("href").removePrefix("/").substringBefore("/")}:${it.text().replaceSpaces()}" } else null
}
"Tags" -> joinToString { element ->
element.text().let {
when {
it.contains("") -> "female:${it.substringBeforeLast(" ").replaceSpaces()}"
it.contains("") -> "male:${it.substringBeforeLast(" ").replaceSpaces()}"
else -> it
}
}
}
else -> joinToString { it.text() }
}
}
Pair(key, value)
}
.plus(Pair("Date uploaded", document.select("div.gallery span.date").text()))
.toMap()
description = tableInfo.filterNot { it.value.isNullOrEmpty() || it.key in listOf("Series", "Characters", "Tags") }.entries.joinToString("\n") { "${it.key}: ${it.value}" }
genre = listOfNotNull(tableInfo["Series"], tableInfo["Characters"], tableInfo["Tags"]).joinToString()
}
}
// Chapters
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
return Observable.just(
listOf(
SChapter.create().apply {
url = manga.url
name = "Chapter"
chapter_number = 0.0f
}
)
)
}
override fun chapterListParse(response: Response) = throw UnsupportedOperationException("Not used")
// Pages
private fun hlIdFromUrl(url: String) =
url.split('/').last().split('-').last().substringBeforeLast('.')
override fun pageListRequest(chapter: SChapter): Request {
return GET("$LTN_BASE_URL/galleries/${hlIdFromUrl(chapter.url)}.js")
}
override fun pageListParse(response: Response): List<Page> {
if (gg.isNullOrEmpty()) {
val response = client.newCall(GET("$LTN_BASE_URL/gg.js")).execute()
gg = response.body!!.string()
}
val duktape = Duktape.create()
duktape.evaluate(gg)
val str = response.body!!.string()
val json = json.decodeFromString<HitomiChapterDto>(str.removePrefix("var galleryinfo = "))
val pages = json.files.mapIndexed { i, jsonElement ->
// https://ltn.hitomi.la/reader.js
// function make_image_element()
val hash = jsonElement.hash
var ext = jsonElement.name.split('.').last()
var path = "images"
var secondSubdomain = "b"
if (hitomiAlwaysWebp() && jsonElement.haswebp == 1) {
path = "webp"
ext = "webp"
secondSubdomain = "a"
}
if (hitomiAlwaysAvif() && jsonElement.hasavif == 1) {
path = "avif"
ext = "avif"
secondSubdomain = "a"
}
val b = duktape.evaluate("gg.b;") as String
val s = duktape.evaluate("gg.s(\"$hash\");") as String
val m = duktape.evaluate("gg.m($s).toString();") as String
var firstSubdomain = "a"
if (m == "1") {
firstSubdomain = "b"
}
Page(i, "", "https://$firstSubdomain$secondSubdomain.hitomi.la/$path/$b$s/$hash.$ext")
}
duktape.close()
return pages
}
override fun imageRequest(page: Page): Request {
val request = super.imageRequest(page)
val hlId = request.url.pathSegments.let {
it[it.lastIndex - 1]
}
return request.newBuilder()
.header("Referer", "$BASE_URL/reader/$hlId.html")
.build()
}
override fun imageUrlParse(response: Response) = throw UnsupportedOperationException("Not used")
companion object {
private const val INDEX_VERSION_CACHE_TIME_MS = 1000 * 60 * 10
private const val PAGE_SIZE = 25
const val PREFIX_ID_SEARCH = "id:"
val NOZOMI_ID_PATTERN = "[0-9]*(?=.html)".toRegex()
val HEXADECIMAL = "0[xX][0-9a-fA-F]+".toRegex()
// Common English words and Japanese particles
private val COMMON_WORDS = listOf(
"a", "be", "boy", "de", "girl", "ga", "i", "is", "ka", "na",
"ni", "ne", "no", "suru", "to", "wa", "wo", "yo",
"b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z"
)
// From HitomiSearchMetaData
const val LTN_BASE_URL = "https://ltn.hitomi.la"
const val BASE_URL = "https://hitomi.la"
// Filter
private val FILTER_CATEGORIES = listOf(
"tag", "male", "female", "type",
"artist", "series", "character", "group"
)
private const val Filter_SEARCH_MESSAGE = "NOTE: Ignored if using text search!"
// Preferences
private const val WEBP_PREF_KEY = "HITOMI_WEBP"
private const val WEBP_PREF_TITLE = "Webp pages"
private const val WEBP_PREF_SUMMARY = "Download webp pages instead of jpeg (when available)"
private const val WEBP_PREF_DEFAULT_VALUE = true
private const val AVIF_PREF_KEY = "HITOMI_AVIF"
private const val AVIF_PREF_TITLE = "Avif pages"
private const val AVIF_PREF_SUMMARY = "Download avif pages instead of jpeg or webp (when available)"
private const val AVIF_PREF_DEFAULT_VALUE = true
private const val COVER_PREF_KEY = "HITOMI_COVERS"
private const val COVER_PREF_TITLE = "Use HQ covers"
private const val COVER_PREF_SUMMARY = "See HQ covers while browsing"
private const val COVER_PREF_DEFAULT_VALUE = true
}
// Preferences
private val preferences: SharedPreferences by lazy {
Injekt.get<Application>().getSharedPreferences("source_$id", 0x0000)
}
override fun setupPreferenceScreen(screen: AndroidXPreferenceScreen) {
val webpPref = AndroidXCheckBoxPreference(screen.context).apply {
key = "${WEBP_PREF_KEY}_$lang"
title = WEBP_PREF_TITLE
summary = WEBP_PREF_SUMMARY
setDefaultValue(WEBP_PREF_DEFAULT_VALUE)
setOnPreferenceChangeListener { _, newValue ->
val checkValue = newValue as Boolean
preferences.edit().putBoolean("${WEBP_PREF_KEY}_$lang", checkValue).commit()
}
}
val avifPref = AndroidXCheckBoxPreference(screen.context).apply {
key = "${AVIF_PREF_KEY}_$lang"
title = AVIF_PREF_TITLE
summary = AVIF_PREF_SUMMARY
setDefaultValue(AVIF_PREF_DEFAULT_VALUE)
setOnPreferenceChangeListener { _, newValue ->
val checkValue = newValue as Boolean
preferences.edit().putBoolean("${AVIF_PREF_KEY}_$lang", checkValue).commit()
}
}
val coverPref = AndroidXCheckBoxPreference(screen.context).apply {
key = "${COVER_PREF_KEY}_$lang"
title = COVER_PREF_TITLE
summary = COVER_PREF_SUMMARY
setDefaultValue(COVER_PREF_DEFAULT_VALUE)
setOnPreferenceChangeListener { _, newValue ->
val checkValue = newValue as Boolean
preferences.edit().putBoolean("${COVER_PREF_KEY}_$lang", checkValue).commit()
}
}
screen.addPreference(webpPref)
screen.addPreference(avifPref)
screen.addPreference(coverPref)
}
private fun hitomiAlwaysWebp(): Boolean = preferences.getBoolean("${WEBP_PREF_KEY}_$lang", WEBP_PREF_DEFAULT_VALUE)
private fun hitomiAlwaysAvif(): Boolean = preferences.getBoolean("${AVIF_PREF_KEY}_$lang", AVIF_PREF_DEFAULT_VALUE)
private fun useHqThumbPref(): Boolean = preferences.getBoolean("${COVER_PREF_KEY}_$lang", COVER_PREF_DEFAULT_VALUE)
}

View File

@ -1,38 +0,0 @@
package eu.kanade.tachiyomi.extension.all.hitomi
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.Intent
import android.os.Bundle
import android.util.Log
import kotlin.system.exitProcess
/**
* Springboard that accepts https://hitomi.la/cg/xxxx intents
* and redirects them to the main Tachiyomi process.
*/
class HitomiActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val pathSegments = intent?.data?.pathSegments
if (pathSegments != null && pathSegments.size > 1) {
val id = pathSegments[1]
val mainIntent = Intent().apply {
action = "eu.kanade.tachiyomi.SEARCH"
putExtra("query", "${Hitomi.PREFIX_ID_SEARCH}$id")
putExtra("filter", packageName)
}
try {
startActivity(mainIntent)
} catch (e: ActivityNotFoundException) {
Log.e("HitomiActivity", e.toString())
}
} else {
Log.e("HitomiActivity", "Could not parse URI from intent $intent")
}
finish()
exitProcess(0)
}
}

View File

@ -1,16 +0,0 @@
package eu.kanade.tachiyomi.extension.all.hitomi
import kotlinx.serialization.Serializable
@Serializable
data class HitomiChapterDto(
val files: List<HitomiFileDto> = emptyList(),
)
@Serializable
data class HitomiFileDto(
val name: String,
val hasavif: Int,
val hash: String,
val haswebp: Int,
)

View File

@ -1,50 +0,0 @@
package eu.kanade.tachiyomi.extension.all.hitomi
import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.SourceFactory
class HitomiFactory : SourceFactory {
override fun createSources(): List<Source> = languageList
.map { Hitomi(it.first, it.second) }
}
private val languageList = listOf(
Pair("all", "all"), // all languages
Pair("id", "indonesian"),
Pair("ca", "catalan"),
Pair("ceb", "cebuano"),
Pair("cs", "czech"),
Pair("da", "danish"),
Pair("de", "german"),
Pair("et", "estonian"),
Pair("en", "english"),
Pair("es", "spanish"),
Pair("eo", "esperanto"),
Pair("fr", "french"),
Pair("it", "italian"),
Pair("la", "latin"),
Pair("hu", "hungarian"),
Pair("nl", "dutch"),
Pair("no", "norwegian"),
Pair("pl", "polish"),
Pair("pt-BR", "portuguese"),
Pair("ro", "romanian"),
Pair("sq", "albanian"),
Pair("sk", "slovak"),
Pair("fi", "finnish"),
Pair("sv", "swedish"),
Pair("tl", "tagalog"),
Pair("vi", "vietnamese"),
Pair("tr", "turkish"),
Pair("el", "greek"),
Pair("mn", "mongolian"),
Pair("ru", "russian"),
Pair("uk", "ukrainian"),
Pair("he", "hebrew"),
Pair("ar", "arabic"),
Pair("fa", "persian"),
Pair("th", "thai"),
Pair("ko", "korean"),
Pair("zh", "chinese"),
Pair("ja", "japanese")
)

View File

@ -1,257 +0,0 @@
package eu.kanade.tachiyomi.extension.all.hitomi
import eu.kanade.tachiyomi.extension.all.hitomi.Hitomi.Companion.LTN_BASE_URL
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.asObservable
import eu.kanade.tachiyomi.network.asObservableSuccess
import okhttp3.Headers
import okhttp3.OkHttpClient
import okhttp3.Request
import rx.Observable
import rx.Single
import java.security.MessageDigest
private typealias HashedTerm = ByteArray
private data class DataPair(val offset: Long, val length: Int)
private data class Node(
val keys: List<ByteArray>,
val datas: List<DataPair>,
val subnodeAddresses: List<Long>
)
/**
* Kotlin port of the hitomi.la search algorithm
* @author NerdNumber9
*/
class HitomiNozomi(
private val client: OkHttpClient,
private val tagIndexVersion: Long,
private val galleriesIndexVersion: Long
) {
fun getGalleryIdsForQuery(query: String, language: String, popular: Boolean): Single<List<Int>> {
if (':' in query) {
val sides = query.split(':')
val namespace = sides[0]
var tag = sides[1]
var area: String? = namespace
if (namespace == "female" || namespace == "male") {
area = "tag"
tag = query
} else if (namespace == "language") {
return getGalleryIdsFromNozomi(null, "index", tag, popular)
}
return getGalleryIdsFromNozomi(area, tag, language, popular)
}
val key = hashTerm(query)
val field = "galleries"
return getNodeAtAddress(field, 0).flatMap { node ->
if (node == null) {
Single.just(null)
} else {
BSearch(field, key, node).flatMap { data ->
if (data == null) {
Single.just(null)
} else {
getGalleryIdsFromData(data)
}
}
}
}
}
private fun getGalleryIdsFromData(data: DataPair?): Single<List<Int>> {
if (data == null) {
return Single.just(emptyList())
}
val url = "$LTN_BASE_URL/$GALLERIES_INDEX_DIR/galleries.$galleriesIndexVersion.data"
val (offset, length) = data
if (length > 100000000 || length <= 0) {
return Single.just(emptyList())
}
return client.newCall(rangedGet(url, offset, offset + length - 1))
.asObservable()
.map {
it.body?.bytes() ?: ByteArray(0)
}
.onErrorReturn { ByteArray(0) }
.map { inbuf ->
if (inbuf.isEmpty()) {
return@map emptyList<Int>()
}
val view = ByteCursor(inbuf)
val numberOfGalleryIds = view.nextInt()
val expectedLength = numberOfGalleryIds * 4 + 4
if (numberOfGalleryIds > 10000000 ||
numberOfGalleryIds <= 0 ||
inbuf.size != expectedLength
) {
return@map emptyList<Int>()
}
(1..numberOfGalleryIds).map {
view.nextInt()
}
}.toSingle()
}
@Suppress("FunctionName")
private fun BSearch(field: String, key: ByteArray, node: Node?): Single<DataPair?> {
fun compareByteArrays(dv1: ByteArray, dv2: ByteArray): Int {
val top = dv1.size.coerceAtMost(dv2.size)
for (i in 0 until top) {
val dv1i = dv1[i].toInt() and 0xFF
val dv2i = dv2[i].toInt() and 0xFF
if (dv1i < dv2i) {
return -1
} else if (dv1i > dv2i) {
return 1
}
}
return 0
}
fun locateKey(key: ByteArray, node: Node): Pair<Boolean, Int> {
var cmpResult = -1
var lastI = 0
for (nodeKey in node.keys) {
cmpResult = compareByteArrays(key, nodeKey)
if (cmpResult <= 0) break
lastI++
}
return (cmpResult == 0) to lastI
}
fun isLeaf(node: Node): Boolean {
return !node.subnodeAddresses.any {
it != 0L
}
}
if (node == null || node.keys.isEmpty()) {
return Single.just(null)
}
val (there, where) = locateKey(key, node)
if (there) {
return Single.just(node.datas[where])
} else if (isLeaf(node)) {
return Single.just(null)
}
return getNodeAtAddress(field, node.subnodeAddresses[where]).flatMap { newNode ->
BSearch(field, key, newNode)
}
}
private fun decodeNode(data: ByteArray): Node {
val view = ByteCursor(data)
val numberOfKeys = view.nextInt()
val keys = (1..numberOfKeys).map {
val keySize = view.nextInt()
view.next(keySize)
}
val numberOfDatas = view.nextInt()
val datas = (1..numberOfDatas).map {
val offset = view.nextLong()
val length = view.nextInt()
DataPair(offset, length)
}
val numberOfSubnodeAddresses = B + 1
val subnodeAddresses = (1..numberOfSubnodeAddresses).map {
view.nextLong()
}
return Node(keys, datas, subnodeAddresses)
}
private fun getNodeAtAddress(field: String, address: Long): Single<Node?> {
var url = "$LTN_BASE_URL/$INDEX_DIR/$field.$tagIndexVersion.index"
if (field == "galleries") {
url = "$LTN_BASE_URL/$GALLERIES_INDEX_DIR/galleries.$galleriesIndexVersion.index"
}
return client.newCall(rangedGet(url, address, address + MAX_NODE_SIZE - 1))
.asObservableSuccess()
.map {
it.body?.bytes() ?: ByteArray(0)
}
.onErrorReturn { ByteArray(0) }
.map { nodedata ->
if (nodedata.isNotEmpty()) {
decodeNode(nodedata)
} else null
}.toSingle()
}
fun getGalleryIdsFromNozomi(area: String?, tag: String, language: String, popular: Boolean): Single<List<Int>> {
val replacedTag = tag.replace('_', ' ')
var nozomiAddress = "$LTN_BASE_URL/$COMPRESSED_NOZOMI_PREFIX/$replacedTag-$language$NOZOMI_EXTENSION"
if (area != null) {
nozomiAddress = if (popular) {
"$LTN_BASE_URL/$COMPRESSED_NOZOMI_PREFIX/$area/popular/$replacedTag-$language$NOZOMI_EXTENSION"
} else {
"$LTN_BASE_URL/$COMPRESSED_NOZOMI_PREFIX/$area/$replacedTag-$language$NOZOMI_EXTENSION"
}
}
return client.newCall(
Request.Builder()
.url(nozomiAddress)
.build()
)
.asObservableSuccess()
.map { resp ->
val body = resp.body!!.bytes()
val cursor = ByteCursor(body)
(1..body.size / 4).map {
cursor.nextInt()
}
}.toSingle()
}
private fun hashTerm(query: String): HashedTerm {
val md = MessageDigest.getInstance("SHA-256")
md.update(query.toByteArray(HASH_CHARSET))
return md.digest().copyOf(4)
}
companion object {
private const val INDEX_DIR = "tagindex"
private const val GALLERIES_INDEX_DIR = "galleriesindex"
private const val COMPRESSED_NOZOMI_PREFIX = "n"
private const val NOZOMI_EXTENSION = ".nozomi"
private const val MAX_NODE_SIZE = 464
private const val B = 16
private val HASH_CHARSET = Charsets.UTF_8
fun rangedGet(url: String, rangeBegin: Long, rangeEnd: Long?): Request {
return GET(
url,
Headers.Builder()
.add("Range", "bytes=$rangeBegin-${rangeEnd ?: ""}")
.build()
)
}
fun getIndexVersion(httpClient: OkHttpClient, name: String): Observable<Long> {
return httpClient.newCall(GET("$LTN_BASE_URL/$name/version?_=${System.currentTimeMillis()}"))
.asObservableSuccess()
.map { it.body!!.string().toLong() }
}
}
}