diff --git a/README.md b/README.md index 14d9b2617..9af91415f 100644 --- a/README.md +++ b/README.md @@ -43,6 +43,7 @@ TachiyomiEH is a fork of the [original Tachiyomi app](https://github.com/inorich * E-Hentai * ExHentai * PervEden +* nhentai TachiyomiEH is fully compatible with Tachiyomi source extensions. Backups from Tachiyomi are also compatible with TachiyomiEH (and vice versa). diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/SourceManager.kt b/app/src/main/java/eu/kanade/tachiyomi/source/SourceManager.kt index 84e9f4dbf..d368cce20 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/SourceManager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/SourceManager.kt @@ -13,6 +13,7 @@ import eu.kanade.tachiyomi.source.online.all.EHentai import eu.kanade.tachiyomi.source.online.all.EHentaiMetadata import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.source.online.YamlHttpSource +import eu.kanade.tachiyomi.source.online.all.NHentai import eu.kanade.tachiyomi.source.online.all.PervEden import eu.kanade.tachiyomi.source.online.english.* import eu.kanade.tachiyomi.source.online.german.WieManga @@ -99,6 +100,7 @@ open class SourceManager(private val context: Context) { } exSrcs += PervEden(PERV_EDEN_EN_SOURCE_ID, "en") exSrcs += PervEden(PERV_EDEN_IT_SOURCE_ID, "it") + exSrcs += NHentai(context) return exSrcs } diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/all/EHentai.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/all/EHentai.kt index edcee3422..fd6300137 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/online/all/EHentai.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/all/EHentai.kt @@ -164,10 +164,7 @@ class EHentai(override val id: Long, exh = this@EHentai.exh title = select("#gn").text().nullIfBlank()?.trim() - altTitles.clear() - select("#gj").text().nullIfBlank()?.trim()?.let { newAltTitle -> - altTitles.add(newAltTitle) - } + altTitle = select("#gj").text().nullIfBlank()?.trim() thumbnailUrl = select("#gd1 img").attr("src").nullIfBlank()?.trim() diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/all/NHentai.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/all/NHentai.kt new file mode 100644 index 000000000..47ac8c2bf --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/all/NHentai.kt @@ -0,0 +1,227 @@ +package eu.kanade.tachiyomi.source.online.all + +import android.content.Context +import android.net.Uri +import com.github.salomonbrys.kotson.get +import com.github.salomonbrys.kotson.int +import com.github.salomonbrys.kotson.long +import com.github.salomonbrys.kotson.string +import com.google.gson.JsonElement +import com.google.gson.JsonNull +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import eu.kanade.tachiyomi.BuildConfig +import eu.kanade.tachiyomi.R +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.asObservableSuccess +import eu.kanade.tachiyomi.source.model.* +import eu.kanade.tachiyomi.source.online.HttpSource +import exh.NHENTAI_SOURCE_ID +import exh.metadata.MetadataHelper +import exh.metadata.copyTo +import exh.metadata.models.NHentaiMetadata +import exh.metadata.models.Tag +import okhttp3.Request +import okhttp3.Response +import rx.Observable +import timber.log.Timber + +/** + * NHentai source + */ + +class NHentai(context: Context) : HttpSource() { + override fun fetchPopularManga(page: Int): Observable { + //TODO There is currently no way to get the most popular mangas + //TODO Instead, we delegate this to the latest updates thing to avoid confusing users with an empty screen + return fetchLatestUpdates(page) + } + + override fun popularMangaRequest(page: Int): Request { + TODO("Currently unavailable!") + } + + override fun popularMangaParse(response: Response): MangasPage { + TODO("Currently unavailable!") + } + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + //Currently we have no filters + //TODO Filter builder + val uri = Uri.parse("$baseUrl/api/galleries/search").buildUpon() + uri.appendQueryParameter("query", query) + uri.appendQueryParameter("page", page.toString()) + return nhGet(uri.toString(), page) + } + + override fun searchMangaParse(response: Response) + = parseResultPage(response) + + override fun latestUpdatesRequest(page: Int): Request { + val uri = Uri.parse("$baseUrl/api/galleries/all").buildUpon() + uri.appendQueryParameter("page", page.toString()) + return nhGet(uri.toString(), page) + } + + override fun latestUpdatesParse(response: Response) + = parseResultPage(response) + + override fun mangaDetailsParse(response: Response) + = parseGallery(jsonParser.parse(response.body().string()).asJsonObject) + + override fun mangaDetailsRequest(manga: SManga) + = urlToDetailsRequest(manga.url) + + fun urlToDetailsRequest(url: String) + = nhGet(baseUrl + "/api/gallery/" + url.split("/").last()) + + fun parseResultPage(response: Response): MangasPage { + val res = jsonParser.parse(response.body().string()).asJsonObject + + val error = res.get("error") + if(error == null) { + val results = res.getAsJsonArray("result")?.map { + parseGallery(it.asJsonObject) + } + val numPages = res.get("num_pages")?.int + if(results != null && numPages != null) + return MangasPage(results, numPages < response.request().tag() as Int) + } else { + Timber.w("An error occurred while performing the search: $error") + } + return MangasPage(emptyList(), false) + } + + fun rawParseGallery(obj: JsonObject) = NHentaiMetadata().apply { + uploadDate = obj.get("upload_date")?.notNull()?.long + + favoritesCount = obj.get("num_favorites")?.notNull()?.long + + mediaId = obj.get("media_id")?.notNull()?.string + + obj.get("title")?.asJsonObject?.let { + japaneseTitle = it.get("japanese")?.notNull()?.string + shortTitle = it.get("pretty")?.notNull()?.string + englishTitle = it.get("english")?.notNull()?.string + } + + obj.get("images")?.asJsonObject?.let { + coverImageType = it.get("cover")?.get("t")?.notNull()?.asString + it.get("pages")?.asJsonArray?.map { + it?.asJsonObject?.get("t")?.notNull()?.asString + }?.filterNotNull()?.let { + pageImageTypes.clear() + pageImageTypes.addAll(it) + } + thumbnailImageType = it.get("thumbnail")?.get("t")?.notNull()?.asString + } + + scanlator = obj.get("scanlator")?.notNull()?.asString + + id = obj.get("id")?.asLong + + obj.get("tags")?.asJsonArray?.map { + val asObj = it.asJsonObject + Pair(asObj.get("type")?.string, asObj.get("name")?.string) + }?.apply { + tags.clear() + }?.forEach { + if(it.first != null && it.second != null) + tags.getOrPut(it.first!!, { ArrayList() }).add(Tag(it.second!!, false)) + } + } + + fun parseGallery(obj: JsonObject) = rawParseGallery(obj).let { + metadataHelper.writeGallery(it, id) + + SManga.create().apply { + it.copyTo(this) + } + } + + fun lazyLoadMetadata(url: String) = + Observable.fromCallable { + metadataHelper.fetchNhentaiMetadata(url) + ?: client.newCall(urlToDetailsRequest(url)) + .asObservableSuccess() + .map { + rawParseGallery(jsonParser.parse(it.body().string()).asJsonObject) + }.toBlocking().first() + }!! + + override fun fetchChapterList(manga: SManga) + = lazyLoadMetadata(manga.url).map { + listOf(SChapter.create().apply { + url = manga.url + name = "Chapter" + date_upload = it.uploadDate ?: 0 + chapter_number = 1f + }) + }!! + + override fun fetchPageList(chapter: SChapter) + = lazyLoadMetadata(chapter.url).map { metadata -> + if(metadata.mediaId == null) emptyList() + else + metadata.pageImageTypes.mapIndexed { index, s -> + val imageUrl = imageUrlFromType(metadata.mediaId!!, index + 1, s) + Page(index, imageUrl!!, imageUrl) + } + }!! + + override fun fetchImageUrl(page: Page) = Observable.just(page.imageUrl!!)!! + + fun imageUrlFromType(mediaId: String, page: Int, t: String) = NHentaiMetadata.typeToExtension(t)?.let { + "https://i.nhentai.net/galleries/$mediaId/$page.$it" + } + + override fun chapterListParse(response: Response): List { + throw NotImplementedError("Unused method called!") + } + + override fun pageListParse(response: Response): List { + throw NotImplementedError("Unused method called!") + } + + override fun imageUrlParse(response: Response): String { + throw NotImplementedError("Unused method called!") + } + + val appName by lazy { + context.getString(R.string.app_name)!! + } + fun nhGet(url: String, tag: Any? = null) = GET(url) + .newBuilder() + .header("User-Agent", + "Mozilla/5.0 (X11; Linux x86_64) " + + "AppleWebKit/537.36 (KHTML, like Gecko) " + + "Chrome/56.0.2924.87 " + + "Safari/537.36 " + + "$appName/${BuildConfig.VERSION_CODE}") + .tag(tag).build()!! + + override val id = NHENTAI_SOURCE_ID + + override val lang = "all" + + override val name = "nhentai" + + override val baseUrl = NHentaiMetadata.BASE_URL + + override val supportsLatest = true + + companion object { + val jsonParser by lazy { + JsonParser() + } + + val metadataHelper by lazy { + MetadataHelper() + } + } + + fun JsonElement.notNull() = + if(this is JsonNull) + null + else this +} diff --git a/app/src/main/java/exh/EHSourceHelpers.kt b/app/src/main/java/exh/EHSourceHelpers.kt index 34760c536..bcccc1ded 100644 --- a/app/src/main/java/exh/EHSourceHelpers.kt +++ b/app/src/main/java/exh/EHSourceHelpers.kt @@ -13,6 +13,8 @@ val EXH_METADATA_SOURCE_ID = LEWD_SOURCE_SERIES + 4 val PERV_EDEN_EN_SOURCE_ID = LEWD_SOURCE_SERIES + 5 val PERV_EDEN_IT_SOURCE_ID = LEWD_SOURCE_SERIES + 6 +val NHENTAI_SOURCE_ID = LEWD_SOURCE_SERIES + 7 + fun isLewdSource(source: Long) = source in 6900..6999 fun isEhSource(source: Long) = source == EH_SOURCE_ID @@ -23,3 +25,5 @@ fun isExSource(source: Long) = source == EXH_SOURCE_ID fun isPervEdenSource(source: Long) = source == PERV_EDEN_IT_SOURCE_ID || source == PERV_EDEN_EN_SOURCE_ID + +fun isNhentaiSource(source: Long) = source == NHENTAI_SOURCE_ID diff --git a/app/src/main/java/exh/metadata/MetadataHelper.kt b/app/src/main/java/exh/metadata/MetadataHelper.kt index 5c147529a..06d44f557 100644 --- a/app/src/main/java/exh/metadata/MetadataHelper.kt +++ b/app/src/main/java/exh/metadata/MetadataHelper.kt @@ -2,6 +2,7 @@ package exh.metadata import exh.* import exh.metadata.models.ExGalleryMetadata +import exh.metadata.models.NHentaiMetadata import exh.metadata.models.PervEdenGalleryMetadata import exh.metadata.models.SearchableGalleryMetadata import io.paperdb.Paper @@ -11,6 +12,7 @@ class MetadataHelper { fun writeGallery(galleryMetadata: SearchableGalleryMetadata, source: Long) = (if(isExSource(source) || isEhSource(source)) exGalleryBook() else if(isPervEdenSource(source)) pervEdenGalleryBook() + else if(isNhentaiSource(source)) nhentaiGalleryBook() else null)?.write(galleryMetadata.galleryUniqueIdentifier(), galleryMetadata)!! fun fetchEhMetadata(url: String, exh: Boolean): ExGalleryMetadata? @@ -31,11 +33,18 @@ class MetadataHelper { return pervEdenGalleryBook().read(it.galleryUniqueIdentifier()) } + fun fetchNhentaiMetadata(url: String) = NHentaiMetadata().let { + it.url = url + nhentaiGalleryBook().read(it.galleryUniqueIdentifier()) + } + fun fetchMetadata(url: String, source: Long): SearchableGalleryMetadata? { if(isExSource(source) || isEhSource(source)) { return fetchEhMetadata(url, isExSource(source)) } else if(isPervEdenSource(source)) { return fetchPervEdenMetadata(url, source) + } else if(isNhentaiSource(source)) { + return fetchNhentaiMetadata(url) } else { return null } @@ -57,4 +66,6 @@ class MetadataHelper { fun exGalleryBook() = Paper.book("gallery-ex")!! fun pervEdenGalleryBook() = Paper.book("gallery-perveden")!! + + fun nhentaiGalleryBook() = Paper.book("gallery-nhentai")!! } \ No newline at end of file diff --git a/app/src/main/java/exh/metadata/MetdataCopier.kt b/app/src/main/java/exh/metadata/MetdataCopier.kt index 79cccaec3..c97c3d40f 100644 --- a/app/src/main/java/exh/metadata/MetdataCopier.kt +++ b/app/src/main/java/exh/metadata/MetdataCopier.kt @@ -4,10 +4,7 @@ import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.getOrDefault import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.online.all.PervEden -import exh.metadata.models.ExGalleryMetadata -import exh.metadata.models.PervEdenGalleryMetadata -import exh.metadata.models.SearchableGalleryMetadata -import exh.metadata.models.Tag +import exh.metadata.models.* import exh.plusAssign import uy.kohesive.injekt.injectLazy import java.text.SimpleDateFormat @@ -17,8 +14,11 @@ import java.util.* * Copies gallery metadata to a manga object */ -private const val ARTIST_NAMESPACE = "artist" -private const val AUTHOR_NAMESPACE = "author" +private const val EH_ARTIST_NAMESPACE = "artist" +private const val EH_AUTHOR_NAMESPACE = "author" + +private const val NHENTAI_ARTIST_NAMESPACE = "artist" +private const val NHENTAI_CATEGORIES_NAMESPACE = "category" private val ONGOING_SUFFIX = arrayOf( "[ongoing]", @@ -43,17 +43,17 @@ fun ExGalleryMetadata.copyTo(manga: SManga) { //No title bug? val titleObj = if(prefs.useJapaneseTitle().getOrDefault()) - altTitles.firstOrNull() ?: title + altTitle ?: title else title titleObj?.let { manga.title = it } //Set artist (if we can find one) - tags[ARTIST_NAMESPACE]?.let { + tags[EH_ARTIST_NAMESPACE]?.let { if(it.isNotEmpty()) manga.artist = it.joinToString(transform = Tag::name) } //Set author (if we can find one) - tags[AUTHOR_NAMESPACE]?.let { + tags[EH_AUTHOR_NAMESPACE]?.let { if(it.isNotEmpty()) manga.author = it.joinToString(transform = Tag::name) } //Set genre @@ -73,7 +73,7 @@ fun ExGalleryMetadata.copyTo(manga: SManga) { //Build a nice looking description out of what we know val titleDesc = StringBuilder() title?.let { titleDesc += "Title: $it\n" } - altTitles.firstOrNull()?.let { titleDesc += "Alternate Title: $it\n" } + altTitle?.let { titleDesc += "Alternate Title: $it\n" } val detailsDesc = StringBuilder() uploader?.let { detailsDesc += "Uploader: $it\n" } @@ -93,7 +93,6 @@ fun ExGalleryMetadata.copyTo(manga: SManga) { detailsDesc += "\n" } - val tagsDesc = buildTagsDescription(this) manga.description = listOf(titleDesc.toString(), detailsDesc.toString(), tagsDesc.toString()) @@ -146,6 +145,55 @@ fun PervEdenGalleryMetadata.copyTo(manga: SManga) { .joinToString(separator = "\n") } +fun NHentaiMetadata.copyTo(manga: SManga) { + url?.let { manga.url = it } + + //TODO next update allow this to be changed to use HD covers + if(mediaId != null) + NHentaiMetadata.typeToExtension(thumbnailImageType)?.let { + manga.thumbnail_url = "https://t.nhentai.net/galleries/$mediaId/thumb.$it" + } + + manga.title = englishTitle ?: japaneseTitle ?: shortTitle!! + + //Set artist (if we can find one) + tags[NHENTAI_ARTIST_NAMESPACE]?.let { + if(it.isNotEmpty()) manga.artist = it.joinToString(transform = Tag::name) + } + + tags[NHENTAI_CATEGORIES_NAMESPACE]?.let { + if(it.isNotEmpty()) manga.genre = it.joinToString(transform = Tag::name) + } + + //Try to automatically identify if it is ongoing, we try not to be too lenient here to avoid making mistakes + //We default to completed + manga.status = SManga.COMPLETED + englishTitle?.let { t -> + ONGOING_SUFFIX.find { + t.endsWith(it, ignoreCase = true) + }?.let { + manga.status = SManga.ONGOING + } + } + + val titleDesc = StringBuilder() + englishTitle?.let { titleDesc += "English Title: $it\n" } + japaneseTitle?.let { titleDesc += "Japanese Title: $it\n" } + shortTitle?.let { titleDesc += "Short Title: $it\n" } + + val detailsDesc = StringBuilder() + uploadDate?.let { detailsDesc += "Upload Date: ${EX_DATE_FORMAT.format(Date(it))}\n" } + pageImageTypes.size.let { detailsDesc += "Length: $it pages\n" } + favoritesCount?.let { detailsDesc += "Favorited: $it times\n" } + scanlator?.nullIfBlank().let { detailsDesc += "Scanlator: $it\n" } + + val tagsDesc = buildTagsDescription(this) + + manga.description = listOf(titleDesc.toString(), detailsDesc.toString(), tagsDesc.toString()) + .filter(String::isNotBlank) + .joinToString(separator = "\n") +} + private fun buildTagsDescription(metadata: SearchableGalleryMetadata) = StringBuilder("Tags:\n").apply { //BiConsumer only available in Java 8, don't bother calling forEach directly on 'tags' diff --git a/app/src/main/java/exh/metadata/models/ExGalleryMetadata.kt b/app/src/main/java/exh/metadata/models/ExGalleryMetadata.kt index 1b076fae8..f08d8a123 100644 --- a/app/src/main/java/exh/metadata/models/ExGalleryMetadata.kt +++ b/app/src/main/java/exh/metadata/models/ExGalleryMetadata.kt @@ -14,6 +14,9 @@ class ExGalleryMetadata : SearchableGalleryMetadata() { var thumbnailUrl: String? = null + var title: String? = null + var altTitle: String? = null + var genre: String? = null var datePosted: Long? = null @@ -27,6 +30,7 @@ class ExGalleryMetadata : SearchableGalleryMetadata() { var ratingCount: Int? = null var averageRating: Double? = null + override fun getTitles() = listOf(title, altTitle).filterNotNull() private fun splitGalleryUrl() = url?.let { diff --git a/app/src/main/java/exh/metadata/models/NHentaiMetadata.kt b/app/src/main/java/exh/metadata/models/NHentaiMetadata.kt new file mode 100644 index 000000000..52c7f2000 --- /dev/null +++ b/app/src/main/java/exh/metadata/models/NHentaiMetadata.kt @@ -0,0 +1,48 @@ +package exh.metadata.models + +/** + * NHentai metadata + */ + +class NHentaiMetadata : SearchableGalleryMetadata() { + + var id: Long? = null + + var url get() = id?.let { "$BASE_URL/g/$it" } + set(a) { + a?.let { + id = a.split("/").last().toLong() + } + } + + var uploadDate: Long? = null + + var favoritesCount: Long? = null + + var mediaId: String? = null + + var japaneseTitle: String? = null + var englishTitle: String? = null + var shortTitle: String? = null + + var coverImageType: String? = null + var pageImageTypes: MutableList = mutableListOf() + var thumbnailImageType: String? = null + + var scanlator: String? = null + + override fun galleryUniqueIdentifier(): String? = "NHENTAI-$id" + + override fun getTitles() = listOf(japaneseTitle, englishTitle, shortTitle).filterNotNull() + + companion object { + val BASE_URL = "https://nhentai.net" + + fun typeToExtension(t: String?) = + when(t) { + "p" -> "png" + "j" -> "jpg" + else -> null + } + } +} diff --git a/app/src/main/java/exh/metadata/models/PervEdenGalleryMetadata.kt b/app/src/main/java/exh/metadata/models/PervEdenGalleryMetadata.kt index a1d7e75fb..b9770aa7a 100644 --- a/app/src/main/java/exh/metadata/models/PervEdenGalleryMetadata.kt +++ b/app/src/main/java/exh/metadata/models/PervEdenGalleryMetadata.kt @@ -6,6 +6,9 @@ class PervEdenGalleryMetadata : SearchableGalleryMetadata() { var url: String? = null var thumbnailUrl: String? = null + var title: String? = null + var altTitles: MutableList = mutableListOf() + var artist: String? = null var type: String? = null @@ -16,6 +19,8 @@ class PervEdenGalleryMetadata : SearchableGalleryMetadata() { var lang: String? = null + override fun getTitles() = listOf(title).plus(altTitles).filterNotNull() + private fun splitGalleryUrl() = url?.let { Uri.parse(it).pathSegments.filterNot(String::isNullOrBlank) diff --git a/app/src/main/java/exh/metadata/models/SearchableGalleryMetadata.kt b/app/src/main/java/exh/metadata/models/SearchableGalleryMetadata.kt index 5b8df99db..bc33eef24 100644 --- a/app/src/main/java/exh/metadata/models/SearchableGalleryMetadata.kt +++ b/app/src/main/java/exh/metadata/models/SearchableGalleryMetadata.kt @@ -9,11 +9,10 @@ import java.util.HashMap abstract class SearchableGalleryMetadata { var uploader: String? = null - var title: String? = null - val altTitles: MutableList = mutableListOf() - //Being specific about which classes are used in generics to make deserialization easier val tags: HashMap> = HashMap() abstract fun galleryUniqueIdentifier(): String? + + abstract fun getTitles(): List } \ No newline at end of file diff --git a/app/src/main/java/exh/search/SearchEngine.kt b/app/src/main/java/exh/search/SearchEngine.kt index cd5054d15..eb4d6f5a2 100644 --- a/app/src/main/java/exh/search/SearchEngine.kt +++ b/app/src/main/java/exh/search/SearchEngine.kt @@ -28,14 +28,12 @@ class SearchEngine { return true } - val cachedLowercaseTitle = metadata.title?.toLowerCase() - val cachedLowercaseAltTitles = metadata.altTitles.map(String::toLowerCase) + val cachedTitles = metadata.getTitles().map(String::toLowerCase) for(component in query) { if(component is Text) { //Match title - if (component.asRegex().test(cachedLowercaseTitle) - || cachedLowercaseAltTitles.find { component.asRegex().test(it) } != null) { + if (cachedTitles.find { component.asRegex().test(it) } != null) { continue } //Match tags