fix(delegate): migrate NH to the v2 api (#1582)

* fix(delegate): migrate NH to the v2 api

* bump versionId

* remove versionId requirement

* gemini review suggestions

* remove dead code

* remove unused imports

* linging

* unneeded comment

* fix(delegate): migrate NH to the v2 api (jobobby04/TachiyomiSY#1581)

* fix(delegate): migrate NH to the v2 api

* remove extra comment

* remove redundant data

* linting

* Code cleanup

---------

Co-authored-by: Jobobby04 <jobobby04@users.noreply.github.com>
(cherry picked from commit eec1236b8b5904b46d79bb716e29f073c6fe9b34)

* close `response`

* Avoid empty server list

---------

Co-authored-by: Jobobby04 <jobobby04@users.noreply.github.com>
Co-authored-by: Cuong-Tran <16017808+cuong-tran@users.noreply.github.com>
This commit is contained in:
MediocreLegion 2026-04-22 03:31:05 -03:00 committed by GitHub
parent ee7fb26790
commit 80ea323281
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 45 additions and 91 deletions

View file

@ -17,7 +17,6 @@ import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.source.online.MetadataSource
import eu.kanade.tachiyomi.source.online.NamespaceSource
import eu.kanade.tachiyomi.source.online.UrlImportableSource
import eu.kanade.tachiyomi.util.asJsoup
import exh.metadata.metadata.NHentaiSearchMetadata
import exh.metadata.metadata.RaisedSearchMetadata
import exh.metadata.metadata.base.RaisedTag
@ -30,7 +29,7 @@ import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import okhttp3.CacheControl
import okhttp3.Response
import org.jsoup.nodes.Document
import tachiyomi.core.common.util.lang.withIOContext
class NHentai(delegate: HttpSource, val context: Context) :
DelegatedHttpSource(delegate),
@ -72,12 +71,8 @@ class NHentai(delegate: HttpSource, val context: Context) :
}
override suspend fun parseIntoMetadata(metadata: NHentaiSearchMetadata, input: Response) {
val body = input.body.string()
val server = MEDIA_SERVER_REGEX.find(body)?.groupValues?.get(1)?.toInt() ?: 1
val json = GALLERY_JSON_REGEX.find(body)!!.groupValues[1].replace(
UNICODE_ESCAPE_REGEX,
) { it.groupValues[1].toInt(radix = 16).toChar().toString() }
val jsonResponse = jsonParser.decodeFromString<JsonResponse>(json)
if (nhConfig == null) getNhConfig()
val jsonResponse = jsonParser.decodeFromString<JsonResponse>(input.body.string())
with(metadata) {
nhId = jsonResponse.id
@ -88,8 +83,6 @@ class NHentai(delegate: HttpSource, val context: Context) :
mediaId = jsonResponse.mediaId
mediaServer = server
jsonResponse.title?.let { title ->
japaneseTitle = title.japanese
shortTitle = title.pretty
@ -98,16 +91,11 @@ class NHentai(delegate: HttpSource, val context: Context) :
preferredTitle = this@NHentai.preferredTitle
jsonResponse.images?.let { images ->
coverImageType = input.asJsoup(body).parseCoverType()
?: images.cover?.type
images.pages.mapNotNull {
it.type
}.let {
pageImageTypes = it
}
thumbnailImageType = images.thumbnail?.type
}
coverImageUrl =
jsonResponse.cover?.path?.let { "$thumbServer/$it" }
?: jsonResponse.thumbnail?.path?.let { "$thumbServer/$it" }
pageImagePreviewUrls = jsonResponse.pages.mapNotNull { it.thumbnail }
scanlator = jsonResponse.scanlator?.trimOrNull()
@ -128,17 +116,13 @@ class NHentai(delegate: HttpSource, val context: Context) :
}
}
// KMK -->
/**
* Site JSON is saying cover of type `w` but instead it's using cover like `cover.jpg.webp`
*/
private fun Document.parseCoverType(): String? {
return selectFirst("#cover > a > img")?.attr("data-src")
?.substringAfterLast('/')
?.substringAfter('.')
?.first()?.toString()
}
// KMK <--
@Serializable
data class JsonConfig(
@SerialName("image_servers")
val imageServers: List<String> = emptyList(),
@SerialName("thumb_servers")
val thumbServers: List<String> = emptyList(),
)
@Serializable
data class JsonResponse(
@ -146,7 +130,8 @@ class NHentai(delegate: HttpSource, val context: Context) :
@SerialName("media_id")
val mediaId: String? = null,
val title: JsonTitle? = null,
val images: JsonImages? = null,
val cover: JsonPage? = null,
val thumbnail: JsonPage? = null,
val scanlator: String? = null,
@SerialName("upload_date")
val uploadDate: Long? = null,
@ -155,6 +140,7 @@ class NHentai(delegate: HttpSource, val context: Context) :
val numPages: Int? = null,
@SerialName("num_favorites")
val numFavorites: Long? = null,
val pages: List<JsonPage> = emptyList(),
)
@Serializable
@ -164,21 +150,12 @@ class NHentai(delegate: HttpSource, val context: Context) :
val pretty: String? = null,
)
@Serializable
data class JsonImages(
val pages: List<JsonPage> = emptyList(),
val cover: JsonPage? = null,
val thumbnail: JsonPage? = null,
)
@Serializable
data class JsonPage(
@SerialName("t")
val type: String? = null,
@SerialName("w")
val path: String? = null,
val width: Long? = null,
@SerialName("h")
val height: Long? = null,
val thumbnail: String? = null,
)
@Serializable
@ -203,20 +180,16 @@ class NHentai(delegate: HttpSource, val context: Context) :
}
override suspend fun getPagePreviewList(manga: SManga, chapters: List<SChapter>, page: Int): PagePreviewPage {
if (nhConfig == null) getNhConfig()
val metadata = fetchOrLoadMetadata(manga.id()) {
client.newCall(mangaDetailsRequest(manga)).awaitSuccess()
}
return PagePreviewPage(
page,
metadata.pageImageTypes.mapIndexed { index, s ->
metadata.pageImagePreviewUrls.mapIndexed { index, path ->
PagePreviewInfo(
index + 1,
imageUrl = thumbnailUrlFromType(
metadata.mediaId!!,
metadata.mediaServer ?: 1,
index + 1,
s,
)!!,
imageUrl = "$thumbServer/$path",
)
},
false,
@ -224,15 +197,23 @@ class NHentai(delegate: HttpSource, val context: Context) :
)
}
private fun thumbnailUrlFromType(
mediaId: String,
mediaServer: Int,
page: Int,
t: String,
) = NHentaiSearchMetadata.typeToExtension(t)?.let {
"https://t$mediaServer.nhentai.net/galleries/$mediaId/${page}t.$it"
var nhConfig: JsonConfig? = null
suspend fun getNhConfig() {
try {
val body = withIOContext { client.newCall(GET("https://nhentai.net/api/v2/config", headers)).awaitSuccess() }
.use { it.body.string() }
nhConfig = jsonParser.decodeFromString<JsonConfig>(body)
} catch (_: Exception) {
nhConfig = JsonConfig(
(1..4).map { n -> "https://i$n.nhentai.net" },
(1..4).map { n -> "https://t$n.nhentai.net" },
)
}
}
val thumbServer
get() = nhConfig?.thumbServers?.randomOrNull() ?: "https://t1.nhentai.net"
override suspend fun fetchPreviewImage(page: PagePreviewInfo, cacheControl: CacheControl?): Response {
return client.newCachelessCallWithProgress(
if (cacheControl != null) {
@ -248,10 +229,6 @@ class NHentai(delegate: HttpSource, val context: Context) :
private val jsonParser = Json {
ignoreUnknownKeys = true
}
private val GALLERY_JSON_REGEX = Regex(".parse\\(\"(.*)\"\\);")
private val MEDIA_SERVER_REGEX = Regex("media_server\\s*:\\s*(\\d+)")
private val UNICODE_ESCAPE_REGEX = Regex("\\\\u([0-9a-fA-F]{4})")
private const val TITLE_PREF = "Display manga title as:"
}
}

View file

@ -81,8 +81,8 @@ fun NHentaiDescription(state: State.Success, openMetadataViewer: () -> Unit) {
binding.pages.text = context.pluralStringResource(
SYMR.plurals.num_pages,
meta.pageImageTypes.size,
meta.pageImageTypes.size,
meta.pageImagePreviewUrls.size,
meta.pageImagePreviewUrls.size,
)
// KMK -->
binding.pages.bindDrawable(context, R.drawable.ic_baseline_menu_book_24, iconColor, 4.dpToPx)

View file

@ -29,15 +29,12 @@ class NHentaiSearchMetadata : RaisedSearchMetadata() {
var mediaId: String? = null
var mediaServer: Int? = null
var japaneseTitle by titleDelegate(TITLE_TYPE_JAPANESE)
var englishTitle by titleDelegate(TITLE_TYPE_ENGLISH)
var shortTitle by titleDelegate(TITLE_TYPE_SHORT)
var coverImageType: String? = null
var pageImageTypes: List<String> = emptyList()
var thumbnailImageType: String? = null
var coverImageUrl: String? = null
var pageImagePreviewUrls: List<String> = emptyList()
var scanlator: String? = null
@ -46,16 +43,6 @@ class NHentaiSearchMetadata : RaisedSearchMetadata() {
override fun createMangaInfo(manga: SManga): SManga {
val key = nhId?.let { nhIdToPath(it) }
val cover = if (mediaId != null) {
// Default media server for cover is always 1 (see in page header)
val server = mediaServer ?: 1
typeToExtension(coverImageType)?.let {
"https://t$server.nhentai.net/galleries/$mediaId/cover.$it"
}
} else {
null
}
val title = when (preferredTitle) {
TITLE_TYPE_SHORT -> shortTitle ?: englishTitle ?: japaneseTitle ?: manga.title
0, TITLE_TYPE_ENGLISH -> englishTitle ?: japaneseTitle ?: shortTitle ?: manga.title
@ -88,7 +75,7 @@ class NHentaiSearchMetadata : RaisedSearchMetadata() {
return manga.copy(
url = key ?: manga.url,
thumbnail_url = cover ?: manga.thumbnail_url,
thumbnail_url = coverImageUrl ?: manga.thumbnail_url,
title = title,
artist = group ?: manga.artist,
author = artist ?: manga.artist,
@ -116,9 +103,8 @@ class NHentaiSearchMetadata : RaisedSearchMetadata() {
getItem(japaneseTitle) { stringResource(SYMR.strings.japanese_title) },
getItem(englishTitle) { stringResource(SYMR.strings.english_title) },
getItem(shortTitle) { stringResource(SYMR.strings.short_title) },
getItem(coverImageType) { stringResource(SYMR.strings.cover_image_file_type) },
getItem(pageImageTypes.size) { stringResource(SYMR.strings.page_count) },
getItem(thumbnailImageType) { stringResource(SYMR.strings.thumbnail_image_file_type) },
getItem(coverImageUrl) { stringResource(SYMR.strings.thumbnail_url) },
getItem(pageImagePreviewUrls.size) { stringResource(SYMR.strings.page_count) },
getItem(scanlator) { stringResource(MR.strings.scanlator) },
)
}
@ -137,15 +123,6 @@ class NHentaiSearchMetadata : RaisedSearchMetadata() {
private const val NHENTAI_GROUP_NAMESPACE = "group"
const val NHENTAI_CATEGORIES_NAMESPACE = "category"
fun typeToExtension(t: String?) =
when (t) {
"p" -> "png"
"j" -> "jpg"
"g" -> "gif"
"w" -> "webp"
else -> null
}
fun nhUrlToId(url: String) =
url.split("/").last { it.isNotBlank() }.toLong()