Hitomi - ported from TachiyomiSy/EH (#4079)
* Hitomi - ported from TachiyomiSy/EH * enable a couple more languages * nsfw annotation * fix missing import
This commit is contained in:
parent
0c8a6ee453
commit
f2a5c8e440
|
@ -0,0 +1,13 @@
|
|||
apply plugin: 'com.android.application'
|
||||
apply plugin: 'kotlin-android'
|
||||
|
||||
ext {
|
||||
extName = 'Hitomi.la'
|
||||
pkgNameSuffix = 'all.hitomi'
|
||||
extClass = '.HitomiFactory'
|
||||
extVersionCode = 1
|
||||
libVersion = '1.2'
|
||||
containsNsfw = true
|
||||
}
|
||||
|
||||
apply from: "$rootDir/common.gradle"
|
Binary file not shown.
After Width: | Height: | Size: 3.2 KiB |
Binary file not shown.
After Width: | Height: | Size: 1.8 KiB |
Binary file not shown.
After Width: | Height: | Size: 4.0 KiB |
Binary file not shown.
After Width: | Height: | Size: 8.2 KiB |
Binary file not shown.
After Width: | Height: | Size: 12 KiB |
Binary file not shown.
After Width: | Height: | Size: 56 KiB |
|
@ -0,0 +1,93 @@
|
|||
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
|
||||
}
|
|
@ -0,0 +1,392 @@
|
|||
package eu.kanade.tachiyomi.extension.all.hitomi
|
||||
|
||||
import android.app.Application
|
||||
import android.content.SharedPreferences
|
||||
import android.support.v7.preference.CheckBoxPreference
|
||||
import android.support.v7.preference.PreferenceScreen
|
||||
import androidx.preference.CheckBoxPreference as AndroidXCheckBoxPreference
|
||||
import androidx.preference.PreferenceScreen as AndroidXPreferenceScreen
|
||||
import com.github.salomonbrys.kotson.array
|
||||
import com.github.salomonbrys.kotson.get
|
||||
import com.github.salomonbrys.kotson.string
|
||||
import com.google.gson.JsonParser
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.asObservableSuccess
|
||||
import eu.kanade.tachiyomi.source.ConfigurableSource
|
||||
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 okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.jsoup.nodes.Document
|
||||
import rx.Observable
|
||||
import rx.Single
|
||||
import rx.schedulers.Schedulers
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
|
||||
/**
|
||||
* 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 baseUrl = BASE_URL
|
||||
|
||||
// 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 Document.selectFirst(selector: String) = this.select(selector).first()
|
||||
|
||||
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> {
|
||||
val splitQuery = query.split(" ")
|
||||
|
||||
val positive = splitQuery.filter { !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").map { n to it.toSet() } }
|
||||
} else {
|
||||
val q = positive.removeAt(0)
|
||||
hn.flatMap { n -> n.getGalleryIdsForQuery(q).map { n to it.toSet() } }
|
||||
}
|
||||
|
||||
base = positive.fold(base) { acc, q ->
|
||||
acc.flatMap { (nozomi, mangas) ->
|
||||
nozomi.getGalleryIdsForQuery(q).map {
|
||||
nozomi to mangas.intersect(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
base = negative.fold(base) { acc, q ->
|
||||
acc.flatMap { (nozomi, mangas) ->
|
||||
nozomi.getGalleryIdsForQuery(q).map {
|
||||
nozomi to (mangas - it)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return 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")
|
||||
|
||||
// Details
|
||||
|
||||
override fun mangaDetailsParse(response: Response): SManga {
|
||||
val document = response.asJsoup()
|
||||
fun String.replaceSpaces() = this.replace(" ", "_")
|
||||
|
||||
return SManga.create().apply {
|
||||
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")
|
||||
}
|
||||
|
||||
private val jsonParser = JsonParser()
|
||||
|
||||
override fun pageListParse(response: Response): List<Page> {
|
||||
val str = response.body()!!.string()
|
||||
val json = jsonParser.parse(str.removePrefix("var galleryinfo = "))
|
||||
return json["files"].array.mapIndexed { i, jsonElement ->
|
||||
val hash = jsonElement["hash"].string
|
||||
val ext = if (jsonElement["haswebp"].string == "0" || !hitomiAlwaysWebp()) jsonElement["name"].string.split('.').last() else "webp"
|
||||
val path = if (jsonElement["haswebp"].string == "0" || !hitomiAlwaysWebp()) "images" else "webp"
|
||||
val hashPath1 = hash.takeLast(1)
|
||||
val hashPath2 = hash.takeLast(3).take(2)
|
||||
Page(i, "", "https://${subdomainFromGalleryId(hashPath2)}a.hitomi.la/$path/$hashPath1/$hashPath2/$hash.$ext")
|
||||
}
|
||||
}
|
||||
|
||||
// https://ltn.hitomi.la/common.js
|
||||
private fun subdomainFromGalleryId(pathSegment: String): Char {
|
||||
var numberOfFrontends = 3
|
||||
var g = pathSegment.toInt(16)
|
||||
if (g < 0x30) numberOfFrontends = 2
|
||||
if (g < 0x09) g = 1
|
||||
|
||||
return (97 + g.rem(numberOfFrontends)).toChar()
|
||||
}
|
||||
|
||||
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
|
||||
|
||||
// From HitomiSearchMetaData
|
||||
const val LTN_BASE_URL = "https://ltn.hitomi.la"
|
||||
const val BASE_URL = "https://hitomi.la"
|
||||
|
||||
// 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 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: PreferenceScreen) {
|
||||
val webpPref = CheckBoxPreference(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 coverPref = CheckBoxPreference(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(coverPref)
|
||||
}
|
||||
|
||||
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 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(coverPref)
|
||||
}
|
||||
|
||||
private fun hitomiAlwaysWebp(): Boolean = preferences.getBoolean("${WEBP_PREF_KEY}_$lang", WEBP_PREF_DEFAULT_VALUE)
|
||||
private fun useHqThumbPref(): Boolean = preferences.getBoolean("${COVER_PREF_KEY}_$lang", COVER_PREF_DEFAULT_VALUE)
|
||||
}
|
|
@ -0,0 +1,57 @@
|
|||
package eu.kanade.tachiyomi.extension.all.hitomi
|
||||
|
||||
import eu.kanade.tachiyomi.annotations.Nsfw
|
||||
import eu.kanade.tachiyomi.source.Source
|
||||
import eu.kanade.tachiyomi.source.SourceFactory
|
||||
|
||||
@Nsfw
|
||||
class HitomiFactory : SourceFactory {
|
||||
override fun createSources(): List<Source> = languageList
|
||||
.filterNot { it.first.isEmpty() }
|
||||
.map { Hitomi(it.first, it.second) }
|
||||
}
|
||||
|
||||
/**
|
||||
* These should all be valid languages but I was too lazy to look up all the language codes
|
||||
* Replace an empty string with a valid language code to enable that language
|
||||
*/
|
||||
private val languageList = listOf(
|
||||
Pair("other", "all"), // all languages
|
||||
Pair("id", "indonesian"),
|
||||
Pair("", "catalan"),
|
||||
Pair("", "cebuano"),
|
||||
Pair("", "czech"),
|
||||
Pair("", "danish"),
|
||||
Pair("de", "german"),
|
||||
Pair("", "estonian"),
|
||||
Pair("en", "english"),
|
||||
Pair("es", "spanish"),
|
||||
Pair("", "esperanto"),
|
||||
Pair("fr", "french"),
|
||||
Pair("it", "italian"),
|
||||
Pair("", "latin"),
|
||||
Pair("", "hungarian"),
|
||||
Pair("", "dutch"),
|
||||
Pair("", "norwegian"),
|
||||
Pair("pl", "polish"),
|
||||
Pair("pt-BR", "portuguese"),
|
||||
Pair("", "romanian"),
|
||||
Pair("", "albanian"),
|
||||
Pair("", "slovak"),
|
||||
Pair("", "finnish"),
|
||||
Pair("", "swedish"),
|
||||
Pair("", "tagalog"),
|
||||
Pair("vi", "vietnamese"),
|
||||
Pair("tr", "turkish"),
|
||||
Pair("", "greek"),
|
||||
Pair("", "mongolian"),
|
||||
Pair("ru", "russian"),
|
||||
Pair("", "ukrainian"),
|
||||
Pair("", "hebrew"),
|
||||
Pair("ar", "arabic"),
|
||||
Pair("", "persian"),
|
||||
Pair("th", "thai"),
|
||||
Pair("ko", "korean"),
|
||||
Pair("zh", "chinese"),
|
||||
Pair("ja", "japanese")
|
||||
)
|
|
@ -0,0 +1,257 @@
|
|||
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 java.security.MessageDigest
|
||||
import okhttp3.Headers
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import rx.Observable
|
||||
import rx.Single
|
||||
|
||||
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): Single<List<Int>> {
|
||||
val replacedQuery = query.replace('_', ' ')
|
||||
|
||||
if (':' in replacedQuery) {
|
||||
val sides = replacedQuery.split(':')
|
||||
val namespace = sides[0]
|
||||
var tag = sides[1]
|
||||
|
||||
var area: String? = namespace
|
||||
var language = "all"
|
||||
if (namespace == "female" || namespace == "male") {
|
||||
area = "tag"
|
||||
tag = replacedQuery
|
||||
} else if (namespace == "language") {
|
||||
area = null
|
||||
language = tag
|
||||
tag = "index"
|
||||
}
|
||||
|
||||
return getGalleryIdsFromNozomi(area, tag, language)
|
||||
}
|
||||
|
||||
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): Single<List<Int>> {
|
||||
var nozomiAddress = "$LTN_BASE_URL/$COMPRESSED_NOZOMI_PREFIX/$tag-$language$NOZOMI_EXTENSION"
|
||||
if (area != null) {
|
||||
nozomiAddress = "$LTN_BASE_URL/$COMPRESSED_NOZOMI_PREFIX/$area/$tag-$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() }
|
||||
}
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue