Lanraragi delegation

This commit is contained in:
Jobobby04
2025-12-05 13:44:58 -05:00
parent e3b43de298
commit 3ae6c0131b
11 changed files with 477 additions and 1 deletions
+1
View File
@@ -61,6 +61,7 @@ Additional features for some extensions, features include custom description, op
* NHentai
* Puruin
* Tsumino
* LANraragi
## Download
Get the app from our [releases page](https://github.com/jobobby04/tachiyomisy/releases/latest).
@@ -69,6 +69,7 @@ import eu.kanade.tachiyomi.source.Source
import eu.kanade.tachiyomi.source.getNameForMangaInfo
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
@@ -87,6 +88,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
@@ -1089,6 +1091,9 @@ fun metadataDescription(source: Source): MetadataDescriptionComposable? {
is Tsumino -> { state, openMetadataViewer, _ ->
TsuminoDescription(state, openMetadataViewer)
}
is Lanraragi -> { state, openMetadataViewer, _ ->
LanraragiDescription(state, openMetadataViewer)
}
else -> null
}
}
@@ -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,
)
@@ -6,6 +6,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
@@ -277,6 +278,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> =
@@ -0,0 +1,234 @@
package eu.kanade.tachiyomi.source.online.all
import android.content.Context
import android.net.Uri
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(Uri.parse(manga.url).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 > 1) {
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""")
}
}
@@ -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,
@@ -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()
}
},
)
}
@@ -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>
@@ -8,6 +8,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(
@@ -667,6 +667,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>
@@ -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()
}
}
}