Lanraragi delegation

(cherry picked from commit 3ae6c0131b2a975c8e517c4de00e65a3637b01cc)

Co-authored-by: Cuong-Tran <16017808+cuong-tran@users.noreply.github.com>
This commit is contained in:
Jobobby04 2025-12-05 13:44:58 -05:00 committed by Cuong-Tran
parent db22e8add3
commit ad9736a2ba
11 changed files with 476 additions and 1 deletions

View file

@ -126,6 +126,7 @@ Additional features for some extensions, features include custom description, op
* NHentai
* Puruin
* Tsumino
* LANraragi
</details>

View file

@ -90,6 +90,7 @@ import eu.kanade.tachiyomi.source.getNameForMangaInfo
import eu.kanade.tachiyomi.source.isIncognitoModeEnabled
import eu.kanade.tachiyomi.source.online.MetadataSource
import eu.kanade.tachiyomi.source.online.all.EHentai
import eu.kanade.tachiyomi.source.online.all.Lanraragi
import eu.kanade.tachiyomi.source.online.all.MangaDex
import eu.kanade.tachiyomi.source.online.all.NHentai
import eu.kanade.tachiyomi.source.online.english.EightMuses
@ -108,6 +109,7 @@ import exh.source.isEhBasedManga
import exh.ui.metadata.adapters.EHentaiDescription
import exh.ui.metadata.adapters.EightMusesDescription
import exh.ui.metadata.adapters.HBrowseDescription
import exh.ui.metadata.adapters.LanraragiDescription
import exh.ui.metadata.adapters.MangaDexDescription
import exh.ui.metadata.adapters.NHentaiDescription
import exh.ui.metadata.adapters.PururinDescription
@ -1445,6 +1447,9 @@ fun metadataDescription(source: Source): MetadataDescriptionComposable? {
is Tsumino -> { state, openMetadataViewer, _ ->
TsuminoDescription(state, openMetadataViewer)
}
is Lanraragi -> { state, openMetadataViewer, _ ->
LanraragiDescription(state, openMetadataViewer)
}
else -> null
}
}

View file

@ -13,6 +13,7 @@ import eu.kanade.tachiyomi.data.cache.PagePreviewCache
import eu.kanade.tachiyomi.network.await
import eu.kanade.tachiyomi.source.PagePreviewSource
import eu.kanade.tachiyomi.source.online.HttpSource
import exh.source.getMainSource
import logcat.LogPriority
import okhttp3.CacheControl
import okhttp3.Call
@ -249,7 +250,7 @@ class PagePreviewFetcher(
isInCache = { pagePreviewCache.isImageInCache(data.imageUrl) },
writeToCache = { pagePreviewCache.putImageToCache(data.imageUrl, it) },
diskCacheKeyLazy = lazy { imageLoader.components.key(data, options)!! },
sourceLazy = lazy { sourceManager.get(data.source) as? PagePreviewSource },
sourceLazy = lazy { sourceManager.get(data.source)?.getMainSource<PagePreviewSource>() },
callFactoryLazy = callFactoryLazy,
imageLoader = imageLoader,
)

View file

@ -8,6 +8,7 @@ import eu.kanade.tachiyomi.data.download.DownloadManager
import eu.kanade.tachiyomi.extension.ExtensionManager
import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.source.online.all.EHentai
import eu.kanade.tachiyomi.source.online.all.Lanraragi
import eu.kanade.tachiyomi.source.online.all.MangaDex
import eu.kanade.tachiyomi.source.online.all.MergedSource
import eu.kanade.tachiyomi.source.online.all.NHentai
@ -301,6 +302,13 @@ class AndroidSourceManager(
NHentai::class,
true,
),
DelegatedSource(
"LANraragi",
fillInSourceId,
"eu.kanade.tachiyomi.extension.all.lanraragi.LANraragi",
Lanraragi::class,
true,
),
).associateBy { it.originalSourceQualifiedClassName }
val currentDelegatedSources: MutableMap<Long, DelegatedSource> =

View file

@ -0,0 +1,233 @@
package eu.kanade.tachiyomi.source.online.all
import android.content.Context
import android.net.Uri
import androidx.core.net.toUri
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.awaitSuccess
import eu.kanade.tachiyomi.network.newCachelessCallWithProgress
import eu.kanade.tachiyomi.network.parseAs
import eu.kanade.tachiyomi.source.PagePreviewInfo
import eu.kanade.tachiyomi.source.PagePreviewPage
import eu.kanade.tachiyomi.source.PagePreviewSource
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.source.online.MetadataSource
import eu.kanade.tachiyomi.source.online.NamespaceSource
import exh.metadata.MetadataUtil
import exh.metadata.metadata.LanraragiSearchMetadata
import exh.metadata.metadata.base.RaisedTag
import exh.source.DelegatedHttpSource
import kotlinx.coroutines.delay
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.CacheControl
import okhttp3.Request
import okhttp3.Response
import java.io.IOException
import java.time.Instant
import java.time.ZoneOffset
import kotlin.time.Duration.Companion.milliseconds
class Lanraragi(delegate: HttpSource, val context: Context) :
DelegatedHttpSource(delegate),
MetadataSource<LanraragiSearchMetadata, Response>,
NamespaceSource,
PagePreviewSource {
override val metaClass = LanraragiSearchMetadata::class
override fun newMetaInstance() = LanraragiSearchMetadata()
override val lang = delegate.lang
private fun getApiUriBuilder(path: String): Uri.Builder {
return LanraragiSearchMetadata.getApiUriBuilder(baseUrl, path)
}
private fun getReaderId(url: String): String {
return READER_ID_REGEX.find(url)?.groupValues?.get(1) ?: ""
}
private fun getThumbnailId(url: String): String {
return THUMBNAIL_ID_REGEX.find(url)?.groupValues?.get(1) ?: ""
}
// Helper
private suspend fun getRandomID(query: String): String {
val searchRandom = client.newCall(GET("$baseUrl/api/search/random?count=1&$query", headers)).awaitSuccess()
val data = jsonParser.parseToJsonElement(searchRandom.body.string()).jsonObject["data"]
val archive = data!!.jsonArray.firstOrNull()?.jsonObject
// 0.8.2~0.8.7 = id, 0.8.8+ = arcid
return (archive?.get("arcid") ?: archive?.get("id"))?.jsonPrimitive?.content ?: ""
}
private suspend fun customMangaDetailsRequest(manga: SManga): Request {
val id = if (manga.url.startsWith("/api/search/random")) {
getRandomID(manga.url.toUri().encodedQuery.toString())
} else {
getReaderId(manga.url)
}
val uri = getApiUriBuilder("/api/archives/$id/metadata").build()
return GET(uri.toString(), headers)
}
override suspend fun getMangaDetails(manga: SManga): SManga {
val response = client.newCall(customMangaDetailsRequest(manga)).awaitSuccess()
return parseToManga(manga, response)
}
override suspend fun parseIntoMetadata(metadata: LanraragiSearchMetadata, input: Response) {
val archive = with(jsonParser) { input.parseAs<Archive>() }
with(metadata) {
arcId = archive.arcid
title = archive.title
summary = archive.summary
tags.clear()
archive.tags?.split(',')
?.mapTo(tags) {
val tag = it.trim()
if (
tag.startsWith(LanraragiSearchMetadata.LANRARAGI_NAMESPACE_DATE_ADDED) ||
tag.startsWith(LanraragiSearchMetadata.LANRARAGI_NAMESPACE_TIMESTAMP)
) {
val second = tag.substringAfter(':').trim().toLongOrNull()
if (second != null) {
val formattedTag = MetadataUtil.EX_DATE_FORMAT.withZone(ZoneOffset.UTC)
.format(Instant.ofEpochSecond(second))
RaisedTag(
tag.substringBefore(':'),
formattedTag,
LanraragiSearchMetadata.TAG_TYPE_DEFAULT,
)
} else {
RaisedTag(
tag.substringBefore(':'),
tag.substringAfter(':'),
LanraragiSearchMetadata.TAG_TYPE_DEFAULT,
)
}
} else {
RaisedTag(
tag.substringBefore(':', LanraragiSearchMetadata.LANRARAGI_NAMESPACE_OTHER),
tag.substringAfter(':'),
LanraragiSearchMetadata.TAG_TYPE_DEFAULT,
)
}
}
pageCount = archive.pagecount
filename = archive.filename
extension = archive.extension
baseUrl = this@Lanraragi.baseUrl
}
}
@Serializable
data class Archive(
val arcid: String,
val isnew: String,
val tags: String?,
val summary: String?,
val title: String,
val pagecount: Int,
val filename: String,
val extension: String,
)
override suspend fun getPagePreviewList(manga: SManga, chapters: List<SChapter>, page: Int): PagePreviewPage {
val metadata = fetchOrLoadMetadata(manga.id()) {
client.newCall(customMangaDetailsRequest(manga)).awaitSuccess()
}
return PagePreviewPage(
page,
(1..(metadata.pageCount ?: 1)).map { index ->
PagePreviewInfo(
index,
imageUrl = LanraragiSearchMetadata.getThumbnailUri(baseUrl, metadata.arcId!!, index),
)
},
false,
1,
)
}
private suspend fun requestPreviewImage(page: PagePreviewInfo, cacheControl: CacheControl?): Response {
return client.newCachelessCallWithProgress(
if (cacheControl != null) {
GET(page.imageUrl, cache = cacheControl, headers = headers)
} else {
GET(page.imageUrl, headers = headers)
},
page,
).awaitSuccess()
}
@Serializable
data class ThumbnailTask(
val job: Int,
val operation: String,
val success: Int,
)
@Serializable
data class TaskProgress(
val state: String,
)
suspend fun minionJobDone(jobId: Int): Boolean {
return client.newCall(
GET(
getApiUriBuilder("/api/minion/$jobId").build().toString(),
headers = headers,
),
).awaitSuccess().let {
with(jsonParser) {
it.parseAs<TaskProgress>().state == "finished"
}
}
}
override suspend fun fetchPreviewImage(page: PagePreviewInfo, cacheControl: CacheControl?): Response {
return requestPreviewImage(page, cacheControl).let {
if (it.code == 202) {
val task = with(jsonParser) {
it.parseAs<ThumbnailTask>()
}
var tries = 0
do {
if (tries > /* KMK --> */ 0 /* KMK <-- */) {
delay(200.milliseconds)
}
val jobDone = minionJobDone(task.job)
} while (!jobDone && tries++ < 3)
requestPreviewImage(page, cacheControl).apply {
if (code == 202) {
throw IOException("Thumbnail not ready")
}
}
} else {
it
}
}
}
companion object {
private val jsonParser = Json {
ignoreUnknownKeys = true
}
private val READER_ID_REGEX = Regex("""/reader\?id=(\w{40})""")
private val THUMBNAIL_ID_REGEX = Regex("""/(\w{40})/thumbnail""")
}
}

View file

@ -1,6 +1,7 @@
package exh.source
import eu.kanade.tachiyomi.source.AndroidSourceManager
import eu.kanade.tachiyomi.source.online.all.Lanraragi
import eu.kanade.tachiyomi.source.online.all.MangaDex
import eu.kanade.tachiyomi.source.online.all.NHentai
import eu.kanade.tachiyomi.source.online.english.EightMuses
@ -19,6 +20,7 @@ private val DELEGATED_METADATA_SOURCES by lazy {
HBrowse::class,
EightMuses::class,
NHentai::class,
Lanraragi::class,
)
}
@ -44,6 +46,13 @@ fun handleSourceLibrary() {
.map { it.value.sourceId }
.sorted()
lanraragiSourceIds = AndroidSourceManager.currentDelegatedSources
.filter {
it.value.newSourceClass == Lanraragi::class
}
.map { it.value.sourceId }
.sorted()
LIBRARY_UPDATE_EXCLUDED_SOURCES = listOf(
EH_SOURCE_ID,
EXH_SOURCE_ID,

View file

@ -0,0 +1,60 @@
package exh.ui.metadata.adapters
import android.view.LayoutInflater
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.viewinterop.AndroidView
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.databinding.DescriptionAdapterLaBinding
import eu.kanade.tachiyomi.ui.manga.MangaScreenModel.State
import eu.kanade.tachiyomi.util.system.copyToClipboard
import exh.metadata.metadata.LanraragiSearchMetadata
import exh.ui.metadata.adapters.MetadataUIUtil.bindDrawable
import tachiyomi.core.common.i18n.pluralStringResource
import tachiyomi.i18n.sy.SYMR
@Composable
fun LanraragiDescription(state: State.Success, openMetadataViewer: () -> Unit) {
val context = LocalContext.current
AndroidView(
modifier = Modifier.fillMaxWidth(),
factory = { factoryContext ->
DescriptionAdapterLaBinding.inflate(LayoutInflater.from(factoryContext)).root
},
update = {
val meta = state.meta
if (meta == null || meta !is LanraragiSearchMetadata) return@AndroidView
val binding = DescriptionAdapterLaBinding.bind(it)
binding.ext.text = meta.extension?.uppercase().orEmpty()
binding.pages.text = context.pluralStringResource(
SYMR.plurals.num_pages,
meta.pageCount ?: 1,
meta.pageCount ?: 1,
)
binding.pages.bindDrawable(context, R.drawable.ic_baseline_menu_book_24)
binding.moreInfo.bindDrawable(context, R.drawable.ic_info_24dp)
listOf(
binding.pages,
binding.ext,
).forEach { textView ->
textView.setOnLongClickListener {
context.copyToClipboard(
textView.text.toString(),
textView.text.toString(),
)
true
}
}
binding.moreInfo.setOnClickListener {
openMetadataViewer()
}
},
)
}

View file

@ -0,0 +1,48 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<androidx.constraintlayout.widget.ConstraintLayout
android:layout_width="match_parent"
android:layout_height="wrap_content">
<com.google.android.material.textview.MaterialTextView
android:id="@+id/ext"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?android:attr/textColorSecondary"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<com.google.android.material.textview.MaterialTextView
android:id="@+id/pages"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="?attr/textAppearanceBodyMedium"
android:textColor="?android:attr/textColorSecondary"
app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
<Button
android:id="@+id/more_info"
style="?attr/borderlessButtonStyle"
android:layout_width="wrap_content"
android:layout_height="36dp"
android:layout_marginEnd="16dp"
android:text="@string/more_info"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintTop_toTopOf="parent" />
</androidx.constraintlayout.widget.ConstraintLayout>
</LinearLayout>

View file

@ -11,6 +11,8 @@ var metadataDelegatedSourceIds: List<Long> = emptyList()
var nHentaiSourceIds: List<Long> = emptyList()
var lanraragiSourceIds: List<Long> = emptyList()
var mangaDexSourceIds: List<Long> = emptyList()
var LIBRARY_UPDATE_EXCLUDED_SOURCES = listOf(

View file

@ -666,6 +666,9 @@
<string name="manga_updates_id">Manga updates id</string>
<string name="anime_planet_id">Anime planet id</string>
<string name="translated">Translated</string>
<string name="filename">File name</string>
<string name="file_extension">File extension</string>
<string name="base_url">Base url</string>
<!-- Extra gallery info -->
<string name="is_visible">Visible: %1$s</string>

View file

@ -0,0 +1,105 @@
package exh.metadata.metadata
import android.content.Context
import android.net.Uri
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.model.copy
import kotlinx.serialization.Serializable
import tachiyomi.core.common.i18n.stringResource
import tachiyomi.i18n.sy.SYMR
@Serializable
class LanraragiSearchMetadata : RaisedSearchMetadata() {
var url get() = arcId?.let { "/reader?id=$it" }
set(a) {
a?.let {
arcId = a
}
}
var arcId: String? = null
var title: String? = null
var summary: String? = null
var pageCount: Int? = null
var baseUrl: String? = null
var filename: String? = null
var extension: String? = null
override fun createMangaInfo(manga: SManga): SManga {
val key = url
val cover = if (baseUrl != null && arcId != null) {
getThumbnailUri(baseUrl!!, arcId!!, 1)
} else {
null
}
val title = title
// Set artist (if we can find one)
val artist = tags.ofNamespace(LANRARAGI_NAMESPACE_ARTIST).let { tags ->
if (tags.isNotEmpty()) tags.joinToString(transform = { it.name }) else null
}
// Copy tags -> genres
val genres = tagsToGenreString()
// We default to completed
val status = SManga.COMPLETED
return manga.copy(
url = key ?: manga.url,
thumbnail_url = cover ?: manga.thumbnail_url,
title = title ?: manga.title,
artist = artist ?: manga.artist,
author = artist ?: manga.artist,
genre = genres,
status = status,
description = summary ?: manga.description,
)
}
override fun getExtraInfoPairs(context: Context): List<Pair<String, String>> {
return with(context) {
listOfNotNull(
getItem(arcId) { stringResource(SYMR.strings.id) },
getItem(pageCount) { stringResource(SYMR.strings.page_count) },
getItem(filename) { stringResource(SYMR.strings.filename) },
getItem(extension) { stringResource(SYMR.strings.file_extension) },
getItem(baseUrl) { stringResource(SYMR.strings.base_url) },
)
}
}
companion object {
const val TAG_TYPE_DEFAULT = 0
const val LANRARAGI_NAMESPACE_OTHER = "other"
const val LANRARAGI_NAMESPACE_DATE_ADDED = "date_added"
const val LANRARAGI_NAMESPACE_TIMESTAMP = "timestamp"
const val LANRARAGI_NAMESPACE_ARTIST = "artist"
fun getApiUriBuilder(baseUrl: String, path: String): Uri.Builder {
return Uri.parse("$baseUrl$path").buildUpon()
}
fun getThumbnailUri(baseUrl: String, id: String, page: Int): String {
val uri = getApiUriBuilder(baseUrl, "/api/archives/$id/thumbnail")
if (page > 1) {
uri.appendQueryParameter("page", page.toString())
uri.appendQueryParameter("no_fallback", "true")
}
return uri.toString()
}
}
}