diff --git a/README.md b/README.md index 3b8b057b0..f5e6d8c6b 100644 --- a/README.md +++ b/README.md @@ -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). diff --git a/app/src/main/java/eu/kanade/presentation/manga/MangaScreen.kt b/app/src/main/java/eu/kanade/presentation/manga/MangaScreen.kt index 89da97806..f625b1b55 100644 --- a/app/src/main/java/eu/kanade/presentation/manga/MangaScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/manga/MangaScreen.kt @@ -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 } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/coil/PagePreviewFetcher.kt b/app/src/main/java/eu/kanade/tachiyomi/data/coil/PagePreviewFetcher.kt index aa17b84a9..90056e194 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/coil/PagePreviewFetcher.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/coil/PagePreviewFetcher.kt @@ -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() }, callFactoryLazy = callFactoryLazy, imageLoader = imageLoader, ) diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/AndroidSourceManager.kt b/app/src/main/java/eu/kanade/tachiyomi/source/AndroidSourceManager.kt index 017a21dc9..f620a8639 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/source/AndroidSourceManager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/AndroidSourceManager.kt @@ -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 = diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/all/Lanraragi.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/all/Lanraragi.kt new file mode 100644 index 000000000..b6d2184ca --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/all/Lanraragi.kt @@ -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, + 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() } + + 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, 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().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() + } + 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""") + } +} diff --git a/app/src/main/java/exh/source/SourceHelper.kt b/app/src/main/java/exh/source/SourceHelper.kt index cf41b5650..ca5c6f5b6 100644 --- a/app/src/main/java/exh/source/SourceHelper.kt +++ b/app/src/main/java/exh/source/SourceHelper.kt @@ -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, diff --git a/app/src/main/java/exh/ui/metadata/adapters/LanraragiDescriptionAdapter.kt b/app/src/main/java/exh/ui/metadata/adapters/LanraragiDescriptionAdapter.kt new file mode 100644 index 000000000..9573c621d --- /dev/null +++ b/app/src/main/java/exh/ui/metadata/adapters/LanraragiDescriptionAdapter.kt @@ -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() + } + }, + ) +} diff --git a/app/src/main/res/layout/description_adapter_la.xml b/app/src/main/res/layout/description_adapter_la.xml new file mode 100644 index 000000000..4519a8a98 --- /dev/null +++ b/app/src/main/res/layout/description_adapter_la.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + +