Finish some more advanced mangadex delegation features, more to come later
This commit is contained in:
parent
294ade035e
commit
0deb6f6b8d
@ -7,6 +7,7 @@ apply plugin: 'com.mikepenz.aboutlibraries.plugin'
|
||||
apply plugin: 'kotlin-android'
|
||||
apply plugin: 'kotlin-android-extensions'
|
||||
apply plugin: 'kotlin-kapt'
|
||||
apply plugin: 'kotlinx-serialization'
|
||||
apply plugin: 'com.github.zellius.shortcut-helper'
|
||||
// Realm (EH)
|
||||
apply plugin: 'realm-android'
|
||||
@ -292,6 +293,10 @@ dependencies {
|
||||
|
||||
implementation "org.jetbrains.kotlin:kotlin-reflect:$BuildPluginsVersion.KOTLIN"
|
||||
|
||||
// SY for mangadex utils
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-serialization-core:1.0.0-RC"
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-serialization-protobuf:1.0.0-RC"
|
||||
|
||||
|
||||
final coroutines_version = '1.3.9'
|
||||
implementation "org.jetbrains.kotlinx:kotlinx-coroutines-core:$coroutines_version"
|
||||
|
@ -282,7 +282,7 @@
|
||||
android:scheme="https" />
|
||||
|
||||
<!-- MangaDex -->
|
||||
<!-- <data
|
||||
<data
|
||||
android:host="mangadex.org"
|
||||
android:pathPattern="\/(title|manga)\/"
|
||||
android:scheme="http" />
|
||||
@ -297,7 +297,7 @@
|
||||
<data
|
||||
android:host="www.mangadex.org"
|
||||
android:pathPattern="\/(title|manga)\/"
|
||||
android:scheme="https" />-->
|
||||
android:scheme="https" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
<activity
|
||||
|
@ -286,4 +286,6 @@ object PreferenceKeys {
|
||||
const val groupLibraryUpdateType = "group_library_update_type"
|
||||
|
||||
const val useNewSourceNavigation = "use_new_source_navigation"
|
||||
|
||||
const val mangaDexLowQualityCovers = "manga_dex_low_quality_covers"
|
||||
}
|
||||
|
@ -391,4 +391,6 @@ class PreferencesHelper(val context: Context) {
|
||||
fun groupLibraryUpdateType() = flowPrefs.getEnum(Keys.groupLibraryUpdateType, Values.GroupLibraryMode.GLOBAL)
|
||||
|
||||
fun useNewSourceNavigation() = flowPrefs.getBoolean(Keys.useNewSourceNavigation, false)
|
||||
|
||||
fun mangaDexLowQualityCovers() = flowPrefs.getBoolean(Keys.mangaDexLowQualityCovers, false)
|
||||
}
|
||||
|
@ -16,6 +16,10 @@ class TrackManager(context: Context) {
|
||||
const val SHIKIMORI = 4
|
||||
const val BANGUMI = 5
|
||||
|
||||
// SY --> Mangadex from Neko todo
|
||||
const val MDLIST = 60
|
||||
// SY <--
|
||||
|
||||
// SY -->
|
||||
const val READING = 1
|
||||
const val REREADING = 2
|
||||
|
@ -75,6 +75,11 @@ interface SManga : Serializable {
|
||||
const val ONGOING = 1
|
||||
const val COMPLETED = 2
|
||||
const val LICENSED = 3
|
||||
// SY --> Mangadex specific statuses
|
||||
const val PUBLICATION_COMPLETE = 61
|
||||
const val CANCELLED = 62
|
||||
const val HIATUS = 63
|
||||
// SY <--
|
||||
|
||||
fun create(): SManga {
|
||||
return SMangaImpl()
|
||||
|
@ -2,19 +2,44 @@ 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.asObservableSuccess
|
||||
import eu.kanade.tachiyomi.source.model.FilterList
|
||||
import eu.kanade.tachiyomi.source.model.MangasPage
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
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.UrlImportableSource
|
||||
import eu.kanade.tachiyomi.ui.manga.MangaController
|
||||
import exh.md.handlers.ApiChapterParser
|
||||
import exh.md.handlers.ApiMangaParser
|
||||
import exh.md.handlers.MangaHandler
|
||||
import exh.md.handlers.MangaPlusHandler
|
||||
import exh.md.utils.MdLang
|
||||
import exh.md.utils.MdUtil
|
||||
import exh.metadata.metadata.MangaDexSearchMetadata
|
||||
import exh.source.DelegatedHttpSource
|
||||
import exh.ui.metadata.adapters.MangaDexDescriptionAdapter
|
||||
import exh.util.urlImportFetchSearchManga
|
||||
import kotlin.reflect.KClass
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import okhttp3.CacheControl
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import rx.Observable
|
||||
|
||||
class MangaDex(delegate: HttpSource, val context: Context) :
|
||||
DelegatedHttpSource(delegate),
|
||||
MetadataSource<MangaDexSearchMetadata, Response>,
|
||||
UrlImportableSource {
|
||||
override val lang: String = delegate.lang
|
||||
|
||||
private val mdLang by lazy {
|
||||
MdLang.values().find { it.lang == lang }?.dexLang ?: lang
|
||||
}
|
||||
|
||||
override val matchingHosts: List<String> = listOf("mangadex.org", "www.mangadex.org")
|
||||
|
||||
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> =
|
||||
@ -31,4 +56,46 @@ class MangaDex(delegate: HttpSource, val context: Context) :
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
override fun fetchMangaDetails(manga: SManga): Observable<SManga> {
|
||||
return MangaHandler(client, headers, listOf(mdLang)).fetchMangaDetailsObservable(manga)
|
||||
}
|
||||
|
||||
override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> {
|
||||
return MangaHandler(client, headers, listOf(mdLang)).fetchChapterListObservable(manga)
|
||||
}
|
||||
|
||||
@ExperimentalSerializationApi
|
||||
override fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
|
||||
return if (chapter.scanlator == "MangaPlus") {
|
||||
client.newCall(mangaPlusPageListRequest(chapter))
|
||||
.asObservableSuccess()
|
||||
.map { response ->
|
||||
val chapterId = ApiChapterParser().externalParse(response)
|
||||
MangaPlusHandler(client).fetchPageList(chapterId)
|
||||
}
|
||||
} else super.fetchPageList(chapter)
|
||||
}
|
||||
|
||||
private fun mangaPlusPageListRequest(chapter: SChapter): Request {
|
||||
val chpUrl = chapter.url.substringBefore(MdUtil.apiChapterSuffix)
|
||||
return GET(MdUtil.baseUrl + chpUrl + MdUtil.apiChapterSuffix, headers, CacheControl.FORCE_NETWORK)
|
||||
}
|
||||
|
||||
override fun fetchImage(page: Page): Observable<Response> {
|
||||
return if (page.imageUrl!!.contains("mangaplus", true)) {
|
||||
MangaPlusHandler(network.client).client.newCall(GET(page.imageUrl!!, headers))
|
||||
.asObservableSuccess()
|
||||
} else super.fetchImage(page)
|
||||
}
|
||||
|
||||
override val metaClass: KClass<MangaDexSearchMetadata> = MangaDexSearchMetadata::class
|
||||
|
||||
override fun getDescriptionAdapter(controller: MangaController): MangaDexDescriptionAdapter {
|
||||
return MangaDexDescriptionAdapter(controller)
|
||||
}
|
||||
|
||||
override fun parseIntoMetadata(metadata: MangaDexSearchMetadata, input: Response) {
|
||||
ApiMangaParser(listOf(mdLang)).parseIntoMetadata(metadata, input)
|
||||
}
|
||||
}
|
||||
|
@ -3,6 +3,7 @@ package eu.kanade.tachiyomi.ui.manga
|
||||
import android.content.Context
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import com.elvishew.xlog.XLog
|
||||
import com.google.gson.Gson
|
||||
import com.jakewharton.rxrelay.BehaviorRelay
|
||||
import com.jakewharton.rxrelay.PublishRelay
|
||||
@ -118,7 +119,7 @@ class MangaPresenter(
|
||||
|
||||
// SY -->
|
||||
if (manga.initialized && source.isMetadataSource()) {
|
||||
getMangaMetaObservable().subscribeLatestCache({ view, flatMetadata -> if (flatMetadata != null) view.onNextMetaInfo(flatMetadata) else Timber.d("Invalid metadata") })
|
||||
getMangaMetaObservable().subscribeLatestCache({ view, flatMetadata -> if (flatMetadata != null) view.onNextMetaInfo(flatMetadata) else XLog.d("Invalid metadata") })
|
||||
}
|
||||
// SY <--
|
||||
|
||||
@ -236,7 +237,7 @@ class MangaPresenter(
|
||||
// SY -->
|
||||
.doOnNext {
|
||||
if (source is MetadataSource<*, *> || (source is EnhancedHttpSource && source.enhancedSource is MetadataSource<*, *>)) {
|
||||
getMangaMetaObservable().subscribeLatestCache({ view, flatMetadata -> if (flatMetadata != null) view.onNextMetaInfo(flatMetadata) else Timber.d("Invalid metadata") })
|
||||
getMangaMetaObservable().subscribeLatestCache({ view, flatMetadata -> if (flatMetadata != null) view.onNextMetaInfo(flatMetadata) else XLog.d("Invalid metadata") })
|
||||
}
|
||||
}
|
||||
// SY <--
|
||||
|
@ -275,6 +275,11 @@ class MangaInfoHeaderAdapter(
|
||||
SManga.ONGOING -> R.string.ongoing
|
||||
SManga.COMPLETED -> R.string.completed
|
||||
SManga.LICENSED -> R.string.licensed
|
||||
// SY --> Mangadex specific statuses
|
||||
SManga.HIATUS -> R.string.hiatus
|
||||
SManga.PUBLICATION_COMPLETE -> R.string.publication_complete
|
||||
SManga.CANCELLED -> R.string.cancelled
|
||||
// SY <--
|
||||
else -> R.string.unknown_status
|
||||
}
|
||||
)
|
||||
|
34
app/src/main/java/exh/md/handlers/ApiChapterParser.kt
Normal file
34
app/src/main/java/exh/md/handlers/ApiChapterParser.kt
Normal file
@ -0,0 +1,34 @@
|
||||
package exh.md.handlers
|
||||
|
||||
import com.github.salomonbrys.kotson.string
|
||||
import com.google.gson.JsonParser
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import java.util.Date
|
||||
import okhttp3.Response
|
||||
|
||||
class ApiChapterParser {
|
||||
fun pageListParse(response: Response): List<Page> {
|
||||
val jsonData = response.body!!.string()
|
||||
val json = JsonParser.parseString(jsonData).asJsonObject
|
||||
|
||||
val pages = mutableListOf<Page>()
|
||||
|
||||
val hash = json.get("hash").string
|
||||
val pageArray = json.getAsJsonArray("page_array")
|
||||
val server = json.get("server").string
|
||||
|
||||
pageArray.forEach {
|
||||
val url = "$hash/${it.asString}"
|
||||
pages.add(Page(pages.size, "$server,${response.request.url},${Date().time}", url))
|
||||
}
|
||||
|
||||
return pages
|
||||
}
|
||||
|
||||
fun externalParse(response: Response): String {
|
||||
val jsonData = response.body!!.string()
|
||||
val json = JsonParser.parseString(jsonData).asJsonObject
|
||||
val external = json.get("external").string
|
||||
return external.substringAfterLast("/")
|
||||
}
|
||||
}
|
301
app/src/main/java/exh/md/handlers/ApiMangaParser.kt
Normal file
301
app/src/main/java/exh/md/handlers/ApiMangaParser.kt
Normal file
@ -0,0 +1,301 @@
|
||||
package exh.md.handlers
|
||||
|
||||
import com.elvishew.xlog.XLog
|
||||
import com.github.salomonbrys.kotson.nullInt
|
||||
import com.github.salomonbrys.kotson.obj
|
||||
import com.google.gson.JsonParser
|
||||
import eu.kanade.tachiyomi.data.database.DatabaseHelper
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.source.model.SChapter
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import eu.kanade.tachiyomi.util.asJsoup
|
||||
import exh.md.handlers.serializers.ApiMangaSerializer
|
||||
import exh.md.handlers.serializers.ChapterSerializer
|
||||
import exh.md.utils.MdLang
|
||||
import exh.md.utils.MdUtil
|
||||
import exh.metadata.metadata.MangaDexSearchMetadata
|
||||
import exh.metadata.metadata.base.RaisedTag
|
||||
import exh.metadata.metadata.base.getFlatMetadataForManga
|
||||
import exh.metadata.metadata.base.insertFlatMetadata
|
||||
import java.util.Date
|
||||
import kotlin.math.floor
|
||||
import okhttp3.Response
|
||||
import rx.Completable
|
||||
import rx.Single
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
|
||||
class ApiMangaParser(val langs: List<String>) {
|
||||
val db: DatabaseHelper get() = Injekt.get()
|
||||
|
||||
val metaClass = MangaDexSearchMetadata::class
|
||||
/**
|
||||
* Use reflection to create a new instance of metadata
|
||||
*/
|
||||
private fun newMetaInstance() = metaClass.constructors.find {
|
||||
it.parameters.isEmpty()
|
||||
}?.call()
|
||||
?: error("Could not find no-args constructor for meta class: ${metaClass.qualifiedName}!")
|
||||
/**
|
||||
* Parses metadata from the input and then copies it into the manga
|
||||
*
|
||||
* Will also save the metadata to the DB if possible
|
||||
*/
|
||||
fun parseToManga(manga: SManga, input: Response): Completable {
|
||||
val mangaId = (manga as? Manga)?.id
|
||||
val metaObservable = if (mangaId != null) {
|
||||
// We have to use fromCallable because StorIO messes up the thread scheduling if we use their rx functions
|
||||
Single.fromCallable {
|
||||
db.getFlatMetadataForManga(mangaId).executeAsBlocking()
|
||||
}.map {
|
||||
it?.raise(metaClass) ?: newMetaInstance()
|
||||
}
|
||||
} else {
|
||||
Single.just(newMetaInstance())
|
||||
}
|
||||
|
||||
return metaObservable.map {
|
||||
parseIntoMetadata(it, input)
|
||||
it.copyTo(manga)
|
||||
it
|
||||
}.flatMapCompletable {
|
||||
if (mangaId != null) {
|
||||
it.mangaId = mangaId
|
||||
db.insertFlatMetadata(it.flatten())
|
||||
} else Completable.complete()
|
||||
}
|
||||
}
|
||||
|
||||
fun parseIntoMetadata(metadata: MangaDexSearchMetadata, input: Response) {
|
||||
with(metadata) {
|
||||
try {
|
||||
val networkApiManga = MdUtil.jsonParser.decodeFromString(ApiMangaSerializer.serializer(), input.body!!.string())
|
||||
val networkManga = networkApiManga.manga
|
||||
mdId = MdUtil.getMangaId(input.request.url.toString())
|
||||
mdUrl = input.request.url.toString()
|
||||
title = MdUtil.cleanString(networkManga.title)
|
||||
thumbnail_url = MdUtil.cdnUrl + MdUtil.removeTimeParamUrl(networkManga.cover_url)
|
||||
description = MdUtil.cleanDescription(networkManga.description)
|
||||
author = MdUtil.cleanString(networkManga.author)
|
||||
artist = MdUtil.cleanString(networkManga.artist)
|
||||
lang_flag = networkManga.lang_flag
|
||||
val lastChapter = networkManga.last_chapter?.toFloatOrNull()
|
||||
lastChapter?.let {
|
||||
last_chapter_number = floor(it).toInt()
|
||||
}
|
||||
|
||||
networkManga.rating?.let {
|
||||
rating = it.bayesian ?: it.mean
|
||||
users = it.users
|
||||
}
|
||||
networkManga.links?.let { links ->
|
||||
links.al?.let { anilist_id = it }
|
||||
links.kt?.let { kitsu_id = it }
|
||||
links.mal?.let { my_anime_list_id = it }
|
||||
links.mu?.let { manga_updates_id = it }
|
||||
links.ap?.let { anime_planet_id = it }
|
||||
}
|
||||
val filteredChapters = filterChapterForChecking(networkApiManga)
|
||||
|
||||
val tempStatus = parseStatus(networkManga.status)
|
||||
val publishedOrCancelled =
|
||||
tempStatus == SManga.PUBLICATION_COMPLETE || tempStatus == SManga.CANCELLED
|
||||
if (publishedOrCancelled && isMangaCompleted(networkApiManga, filteredChapters)) {
|
||||
status = SManga.COMPLETED
|
||||
missing_chapters = null
|
||||
} else {
|
||||
status = tempStatus
|
||||
}
|
||||
|
||||
val genres =
|
||||
networkManga.genres.mapNotNull { FilterHandler.allTypes[it.toString()] }
|
||||
.toMutableList()
|
||||
|
||||
if (networkManga.hentai == 1) {
|
||||
genres.add("Hentai")
|
||||
}
|
||||
|
||||
if (tags.size != 0) tags.clear()
|
||||
tags += genres.map { RaisedTag(null, it, MangaDexSearchMetadata.TAG_TYPE_DEFAULT) }
|
||||
} catch (e: Exception) {
|
||||
XLog.e(e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* If chapter title is oneshot or a chapter exists which matches the last chapter in the required language
|
||||
* return manga is complete
|
||||
*/
|
||||
private fun isMangaCompleted(
|
||||
serializer: ApiMangaSerializer,
|
||||
filteredChapters: List<Map.Entry<String, ChapterSerializer>>
|
||||
): Boolean {
|
||||
if (filteredChapters.isEmpty() || serializer.manga.last_chapter.isNullOrEmpty()) {
|
||||
return false
|
||||
}
|
||||
val finalChapterNumber = serializer.manga.last_chapter!!
|
||||
if (MdUtil.validOneShotFinalChapters.contains(finalChapterNumber)) {
|
||||
filteredChapters.firstOrNull()?.let {
|
||||
if (isOneShot(it.value, finalChapterNumber)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
}
|
||||
val removeOneshots = filteredChapters.filter { !it.value.chapter.isNullOrBlank() }
|
||||
return removeOneshots.size.toString() == floor(finalChapterNumber.toDouble()).toInt().toString()
|
||||
}
|
||||
|
||||
private fun filterChapterForChecking(serializer: ApiMangaSerializer): List<Map.Entry<String, ChapterSerializer>> {
|
||||
serializer.chapter ?: return emptyList()
|
||||
return serializer.chapter.entries
|
||||
.filter { langs.contains(it.value.lang_code) }
|
||||
.filter {
|
||||
it.value.chapter?.let { chapterNumber ->
|
||||
if (chapterNumber.toIntOrNull() == null) {
|
||||
return@filter false
|
||||
}
|
||||
return@filter true
|
||||
}
|
||||
return@filter false
|
||||
}.distinctBy { it.value.chapter }
|
||||
}
|
||||
|
||||
private fun isOneShot(chapter: ChapterSerializer, finalChapterNumber: String): Boolean {
|
||||
return chapter.title.equals("oneshot", true) ||
|
||||
((chapter.chapter.isNullOrEmpty() || chapter.chapter == "0") && MdUtil.validOneShotFinalChapters.contains(finalChapterNumber))
|
||||
}
|
||||
|
||||
private fun parseStatus(status: Int) = when (status) {
|
||||
1 -> SManga.ONGOING
|
||||
2 -> SManga.PUBLICATION_COMPLETE
|
||||
3 -> SManga.CANCELLED
|
||||
4 -> SManga.HIATUS
|
||||
else -> SManga.UNKNOWN
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse for the random manga id from the [MdUtil.randMangaPage] response.
|
||||
*/
|
||||
fun randomMangaIdParse(response: Response): String {
|
||||
val randMangaUrl = response.asJsoup()
|
||||
.select("link[rel=canonical]")
|
||||
.attr("href")
|
||||
return MdUtil.getMangaId(randMangaUrl)
|
||||
}
|
||||
|
||||
fun chapterListParse(response: Response): List<SChapter> {
|
||||
return chapterListParse(response.body!!.string())
|
||||
}
|
||||
|
||||
fun chapterListParse(jsonData: String): List<SChapter> {
|
||||
val now = Date().time
|
||||
val networkApiManga =
|
||||
MdUtil.jsonParser.decodeFromString(ApiMangaSerializer.serializer(), jsonData)
|
||||
val networkManga = networkApiManga.manga
|
||||
val networkChapters = networkApiManga.chapter
|
||||
if (networkChapters.isNullOrEmpty()) {
|
||||
return listOf()
|
||||
}
|
||||
val status = networkManga.status
|
||||
|
||||
val finalChapterNumber = networkManga.last_chapter!!
|
||||
|
||||
val chapters = mutableListOf<SChapter>()
|
||||
|
||||
// Skip chapters that don't match the desired language, or are future releases
|
||||
|
||||
val chapLangs = MdLang.values().filter { langs.contains(it.dexLang) }
|
||||
networkChapters.filter { langs.contains(it.value.lang_code) && (it.value.timestamp * 1000) <= now }
|
||||
.mapTo(chapters) { mapChapter(it.key, it.value, finalChapterNumber, status, chapLangs, networkChapters.size) }
|
||||
|
||||
return chapters
|
||||
}
|
||||
|
||||
fun chapterParseForMangaId(response: Response): Int {
|
||||
try {
|
||||
if (response.code != 200) throw Exception("HTTP error ${response.code}")
|
||||
val body = response.body?.string().orEmpty()
|
||||
if (body.isEmpty()) {
|
||||
throw Exception("Null Response")
|
||||
}
|
||||
|
||||
val jsonObject = JsonParser.parseString(body).obj
|
||||
return jsonObject["manga_id"]?.nullInt ?: throw Exception("No manga associated with chapter")
|
||||
} catch (e: Exception) {
|
||||
XLog.e(e)
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
private fun mapChapter(
|
||||
chapterId: String,
|
||||
networkChapter: ChapterSerializer,
|
||||
finalChapterNumber: String,
|
||||
status: Int,
|
||||
chapLangs: List<MdLang>,
|
||||
totalChapterCount: Int
|
||||
): SChapter {
|
||||
val chapter = SChapter.create()
|
||||
chapter.url = MdUtil.apiChapter + chapterId
|
||||
val chapterName = mutableListOf<String>()
|
||||
// Build chapter name
|
||||
|
||||
if (!networkChapter.volume.isNullOrBlank()) {
|
||||
val vol = "Vol." + networkChapter.volume
|
||||
chapterName.add(vol)
|
||||
// todo
|
||||
// chapter.vol = vol
|
||||
}
|
||||
|
||||
if (!networkChapter.chapter.isNullOrBlank()) {
|
||||
val chp = "Ch." + networkChapter.chapter
|
||||
chapterName.add(chp)
|
||||
// chapter.chapter_txt = chp
|
||||
}
|
||||
if (!networkChapter.title.isNullOrBlank()) {
|
||||
if (chapterName.isNotEmpty()) {
|
||||
chapterName.add("-")
|
||||
}
|
||||
// todo
|
||||
chapterName.add(networkChapter.title)
|
||||
// chapter.chapter_title = MdUtil.cleanString(networkChapter.title)
|
||||
}
|
||||
|
||||
// if volume, chapter and title is empty its a oneshot
|
||||
if (chapterName.isEmpty()) {
|
||||
chapterName.add("Oneshot")
|
||||
}
|
||||
if ((status == 2 || status == 3)) {
|
||||
if ((isOneShot(networkChapter, finalChapterNumber) && totalChapterCount == 1) ||
|
||||
networkChapter.chapter == finalChapterNumber
|
||||
) {
|
||||
chapterName.add("[END]")
|
||||
}
|
||||
}
|
||||
|
||||
chapter.name = MdUtil.cleanString(chapterName.joinToString(" "))
|
||||
// Convert from unix time
|
||||
chapter.date_upload = networkChapter.timestamp * 1000
|
||||
val scanlatorName = mutableSetOf<String>()
|
||||
|
||||
networkChapter.group_name?.let {
|
||||
scanlatorName.add(it)
|
||||
}
|
||||
networkChapter.group_name_2?.let {
|
||||
scanlatorName.add(it)
|
||||
}
|
||||
networkChapter.group_name_3?.let {
|
||||
scanlatorName.add(it)
|
||||
}
|
||||
|
||||
chapter.scanlator = MdUtil.cleanString(MdUtil.getScanlatorString(scanlatorName))
|
||||
|
||||
// chapter.mangadex_chapter_id = MdUtil.getChapterId(chapter.url)
|
||||
|
||||
// chapter.language = chapLangs.firstOrNull { it.dexLang == networkChapter.lang_code }?.name
|
||||
|
||||
return chapter
|
||||
}
|
||||
}
|
26
app/src/main/java/exh/md/handlers/CoverHandler.kt
Normal file
26
app/src/main/java/exh/md/handlers/CoverHandler.kt
Normal file
@ -0,0 +1,26 @@
|
||||
package exh.md.handlers
|
||||
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import exh.md.handlers.serializers.CoversResult
|
||||
import exh.md.utils.MdUtil
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.CacheControl
|
||||
import okhttp3.Headers
|
||||
import okhttp3.OkHttpClient
|
||||
|
||||
// Unused, look into what its used for todo
|
||||
class CoverHandler(val client: OkHttpClient, val headers: Headers) {
|
||||
|
||||
suspend fun getCovers(manga: SManga): List<String> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
val response = client.newCall(GET("${MdUtil.baseUrl}${MdUtil.coversApi}${MdUtil.getMangaId(manga.url)}", headers, CacheControl.FORCE_NETWORK)).execute()
|
||||
val result = MdUtil.jsonParser.decodeFromString(
|
||||
CoversResult.serializer(),
|
||||
response.body!!.string()
|
||||
)
|
||||
result.covers.map { "${MdUtil.baseUrl}$it" }
|
||||
}
|
||||
}
|
||||
}
|
179
app/src/main/java/exh/md/handlers/FilterHandler.kt
Normal file
179
app/src/main/java/exh/md/handlers/FilterHandler.kt
Normal file
@ -0,0 +1,179 @@
|
||||
package exh.md.handlers
|
||||
|
||||
import eu.kanade.tachiyomi.source.model.Filter
|
||||
import eu.kanade.tachiyomi.source.model.FilterList
|
||||
|
||||
class FilterHandler {
|
||||
|
||||
class TextField(name: String, val key: String) : Filter.Text(name)
|
||||
class Tag(val id: String, name: String) : Filter.TriState(name)
|
||||
class Switch(val id: String, name: String) : Filter.CheckBox(name)
|
||||
class ContentList(contents: List<Tag>) : Filter.Group<Tag>("Content", contents)
|
||||
class FormatList(formats: List<Tag>) : Filter.Group<Tag>("Format", formats)
|
||||
class GenreList(genres: List<Tag>) : Filter.Group<Tag>("Genres", genres)
|
||||
class PublicationStatusList(statuses: List<Switch>) : Filter.Group<Switch>("Publication Status", statuses)
|
||||
class DemographicList(demographics: List<Switch>) : Filter.Group<Switch>("Demographic", demographics)
|
||||
|
||||
class R18 : Filter.Select<String>("R18+", arrayOf("Default", "Show all", "Show only", "Show none"))
|
||||
class ThemeList(themes: List<Tag>) : Filter.Group<Tag>("Themes", themes)
|
||||
class TagInclusionMode : Filter.Select<String>("Tag inclusion", arrayOf("All (and)", "Any (or)"), 0)
|
||||
class TagExclusionMode : Filter.Select<String>("Tag exclusion", arrayOf("All (and)", "Any (or)"), 1)
|
||||
|
||||
class SortFilter : Filter.Sort(
|
||||
"Sort",
|
||||
sortables().map { it.first }.toTypedArray(),
|
||||
Selection(0, false)
|
||||
)
|
||||
|
||||
class OriginalLanguage : Filter.Select<String>("Original Language", sourceLang().map { it.first }.toTypedArray())
|
||||
|
||||
fun getFilterList() = FilterList(
|
||||
TextField("Author", "author"),
|
||||
TextField("Artist", "artist"),
|
||||
R18(),
|
||||
SortFilter(),
|
||||
DemographicList(demographics()),
|
||||
PublicationStatusList(publicationStatus()),
|
||||
OriginalLanguage(),
|
||||
ContentList(contentType()),
|
||||
FormatList(formats()),
|
||||
GenreList(genre()),
|
||||
ThemeList(themes()),
|
||||
TagInclusionMode(),
|
||||
TagExclusionMode()
|
||||
)
|
||||
|
||||
companion object {
|
||||
fun demographics() = listOf(
|
||||
Switch("1", "Shounen"),
|
||||
Switch("2", "Shoujo"),
|
||||
Switch("3", "Seinen"),
|
||||
Switch("4", "Josei")
|
||||
)
|
||||
|
||||
fun publicationStatus() = listOf(
|
||||
Switch("1", "Ongoing"),
|
||||
Switch("2", "Completed"),
|
||||
Switch("3", "Cancelled"),
|
||||
Switch("4", "Hiatus")
|
||||
)
|
||||
|
||||
fun sortables() = listOf(
|
||||
Triple("Update date", 1, 0),
|
||||
Triple("Alphabetically", 2, 3),
|
||||
Triple("Number of comments", 4, 5),
|
||||
Triple("Rating", 6, 7),
|
||||
Triple("Views", 8, 9),
|
||||
Triple("Follows", 10, 11)
|
||||
)
|
||||
|
||||
fun sourceLang() = listOf(
|
||||
Pair("All", "0"),
|
||||
Pair("Japanese", "2"),
|
||||
Pair("English", "1"),
|
||||
Pair("Polish", "3"),
|
||||
Pair("German", "8"),
|
||||
Pair("French", "10"),
|
||||
Pair("Vietnamese", "12"),
|
||||
Pair("Chinese", "21"),
|
||||
Pair("Indonesian", "27"),
|
||||
Pair("Korean", "28"),
|
||||
Pair("Spanish (LATAM)", "29"),
|
||||
Pair("Thai", "32"),
|
||||
Pair("Filipino", "34")
|
||||
)
|
||||
|
||||
fun contentType() = listOf(
|
||||
Tag("9", "Ecchi"),
|
||||
Tag("32", "Smut"),
|
||||
Tag("49", "Gore"),
|
||||
Tag("50", "Sexual Violence")
|
||||
).sortedWith(compareBy { it.name })
|
||||
|
||||
fun formats() = listOf(
|
||||
Tag("1", "4-koma"),
|
||||
Tag("4", "Award Winning"),
|
||||
Tag("7", "Doujinshi"),
|
||||
Tag("21", "Oneshot"),
|
||||
Tag("36", "Long Strip"),
|
||||
Tag("42", "Adaptation"),
|
||||
Tag("43", "Anthology"),
|
||||
Tag("44", "Web Comic"),
|
||||
Tag("45", "Full Color"),
|
||||
Tag("46", "User Created"),
|
||||
Tag("47", "Official Colored"),
|
||||
Tag("48", "Fan Colored")
|
||||
).sortedWith(compareBy { it.name })
|
||||
|
||||
fun genre() = listOf(
|
||||
Tag("2", "Action"),
|
||||
Tag("3", "Adventure"),
|
||||
Tag("5", "Comedy"),
|
||||
Tag("8", "Drama"),
|
||||
Tag("10", "Fantasy"),
|
||||
Tag("13", "Historical"),
|
||||
Tag("14", "Horror"),
|
||||
Tag("17", "Mecha"),
|
||||
Tag("18", "Medical"),
|
||||
Tag("20", "Mystery"),
|
||||
Tag("22", "Psychological"),
|
||||
Tag("23", "Romance"),
|
||||
Tag("25", "Sci-Fi"),
|
||||
Tag("28", "Shoujo Ai"),
|
||||
Tag("30", "Shounen Ai"),
|
||||
Tag("31", "Slice of Life"),
|
||||
Tag("33", "Sports"),
|
||||
Tag("35", "Tragedy"),
|
||||
Tag("37", "Yaoi"),
|
||||
Tag("38", "Yuri"),
|
||||
Tag("41", "Isekai"),
|
||||
Tag("51", "Crime"),
|
||||
Tag("52", "Magical Girls"),
|
||||
Tag("53", "Philosophical"),
|
||||
Tag("54", "Superhero"),
|
||||
Tag("55", "Thriller"),
|
||||
Tag("56", "Wuxia")
|
||||
).sortedWith(compareBy { it.name })
|
||||
|
||||
fun themes() = listOf(
|
||||
Tag("6", "Cooking"),
|
||||
Tag("11", "Gyaru"),
|
||||
Tag("12", "Harem"),
|
||||
Tag("16", "Martial Arts"),
|
||||
Tag("19", "Music"),
|
||||
Tag("24", "School Life"),
|
||||
Tag("34", "Supernatural"),
|
||||
Tag("40", "Video Games"),
|
||||
Tag("57", "Aliens"),
|
||||
Tag("58", "Animals"),
|
||||
Tag("59", "Crossdressing"),
|
||||
Tag("60", "Demons"),
|
||||
Tag("61", "Delinquents"),
|
||||
Tag("62", "Genderswap"),
|
||||
Tag("63", "Ghosts"),
|
||||
Tag("64", "Monster Girls"),
|
||||
Tag("65", "Loli"),
|
||||
Tag("66", "Magic"),
|
||||
Tag("67", "Military"),
|
||||
Tag("68", "Monsters"),
|
||||
Tag("69", "Ninja"),
|
||||
Tag("70", "Office Workers"),
|
||||
Tag("71", "Police"),
|
||||
Tag("72", "Post-Apocalyptic"),
|
||||
Tag("73", "Reincarnation"),
|
||||
Tag("74", "Reverse Harem"),
|
||||
Tag("75", "Samurai"),
|
||||
Tag("76", "Shota"),
|
||||
Tag("77", "Survival"),
|
||||
Tag("78", "Time Travel"),
|
||||
Tag("79", "Vampires"),
|
||||
Tag("80", "Traditional Games"),
|
||||
Tag("81", "Virtual Reality"),
|
||||
Tag("82", "Zombies"),
|
||||
Tag("83", "Incest"),
|
||||
Tag("84", "Mafia")
|
||||
).sortedWith(compareBy { it.name })
|
||||
|
||||
val allTypes = (contentType() + formats() + genre() + themes()).map { it.id to it.name }.toMap()
|
||||
}
|
||||
}
|
240
app/src/main/java/exh/md/handlers/FollowsHandler.kt
Normal file
240
app/src/main/java/exh/md/handlers/FollowsHandler.kt
Normal file
@ -0,0 +1,240 @@
|
||||
package exh.md.handlers
|
||||
|
||||
import com.elvishew.xlog.XLog
|
||||
import eu.kanade.tachiyomi.data.database.models.Track
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.data.track.TrackManager
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.POST
|
||||
import eu.kanade.tachiyomi.network.asObservable
|
||||
import eu.kanade.tachiyomi.source.model.MetadataMangasPage
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import exh.md.handlers.serializers.FollowsPageResult
|
||||
import exh.md.handlers.serializers.Result
|
||||
import exh.md.utils.FollowStatus
|
||||
import exh.md.utils.MdUtil
|
||||
import exh.md.utils.MdUtil.Companion.baseUrl
|
||||
import exh.md.utils.MdUtil.Companion.getMangaId
|
||||
import exh.metadata.metadata.MangaDexSearchMetadata
|
||||
import kotlin.math.floor
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.CacheControl
|
||||
import okhttp3.FormBody
|
||||
import okhttp3.Headers
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import rx.Observable
|
||||
|
||||
// Unused, kept for future featues todo
|
||||
class FollowsHandler(val client: OkHttpClient, val headers: Headers, val preferences: PreferencesHelper) {
|
||||
|
||||
/**
|
||||
* fetch follows by page
|
||||
*/
|
||||
fun fetchFollows(page: Int): Observable<MetadataMangasPage> {
|
||||
return client.newCall(followsListRequest(page))
|
||||
.asObservable()
|
||||
.map { response ->
|
||||
followsParseMangaPage(response)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse follows api to manga page
|
||||
* used when multiple follows
|
||||
*/
|
||||
private fun followsParseMangaPage(response: Response, forceHd: Boolean = false): MetadataMangasPage {
|
||||
var followsPageResult: FollowsPageResult? = null
|
||||
|
||||
try {
|
||||
followsPageResult =
|
||||
MdUtil.jsonParser.decodeFromString(
|
||||
FollowsPageResult.serializer(),
|
||||
response.body!!.string()
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
XLog.e("error parsing follows", e)
|
||||
}
|
||||
val empty = followsPageResult?.result?.isEmpty()
|
||||
|
||||
if (empty == null || empty) {
|
||||
return MetadataMangasPage(mutableListOf(), false, mutableListOf())
|
||||
}
|
||||
val lowQualityCovers = if (forceHd) false else preferences.mangaDexLowQualityCovers().get()
|
||||
|
||||
val follows = followsPageResult!!.result.map {
|
||||
followFromElement(it, lowQualityCovers)
|
||||
}
|
||||
|
||||
return MetadataMangasPage(follows.map { it.first }, true, follows.map { it.second })
|
||||
}
|
||||
|
||||
/**fetch follow status used when fetching status for 1 manga
|
||||
*
|
||||
*/
|
||||
|
||||
private fun followStatusParse(response: Response): Track {
|
||||
var followsPageResult: FollowsPageResult? = null
|
||||
|
||||
try {
|
||||
followsPageResult =
|
||||
MdUtil.jsonParser.decodeFromString(
|
||||
FollowsPageResult.serializer(),
|
||||
response.body!!.string()
|
||||
)
|
||||
} catch (e: Exception) {
|
||||
XLog.e("error parsing follows", e)
|
||||
}
|
||||
val track = Track.create(TrackManager.MDLIST)
|
||||
val result = followsPageResult?.result
|
||||
if (result.isNullOrEmpty()) {
|
||||
track.status = FollowStatus.UNFOLLOWED.int
|
||||
} else {
|
||||
val follow = result.first()
|
||||
track.status = follow.follow_type
|
||||
if (result[0].chapter.isNotBlank()) {
|
||||
track.last_chapter_read = floor(follow.chapter.toFloat()).toInt()
|
||||
}
|
||||
track.tracking_url = MdUtil.baseUrl + follow.manga_id.toString()
|
||||
track.title = follow.title
|
||||
}
|
||||
return track
|
||||
}
|
||||
|
||||
/**build Request for follows page
|
||||
*
|
||||
*/
|
||||
private fun followsListRequest(page: Int): Request {
|
||||
val url = "${MdUtil.baseUrl}${MdUtil.followsAllApi}".toHttpUrlOrNull()!!.newBuilder()
|
||||
.addQueryParameter("page", page.toString())
|
||||
|
||||
return GET(url.toString(), headers, CacheControl.FORCE_NETWORK)
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse result element to manga
|
||||
*/
|
||||
private fun followFromElement(result: Result, lowQualityCovers: Boolean): Pair<SManga, MangaDexSearchMetadata> {
|
||||
val manga = SManga.create()
|
||||
manga.title = MdUtil.cleanString(result.title)
|
||||
manga.url = "/manga/${result.manga_id}/"
|
||||
manga.thumbnail_url = MdUtil.formThumbUrl(manga.url, lowQualityCovers)
|
||||
return manga to MangaDexSearchMetadata().apply {
|
||||
title = MdUtil.cleanString(result.title)
|
||||
mdUrl = "/manga/${result.manga_id}/"
|
||||
thumbnail_url = MdUtil.formThumbUrl(manga.url, lowQualityCovers)
|
||||
follow_status = FollowStatus.fromInt(result.follow_type)?.ordinal
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Change the status of a manga
|
||||
*/
|
||||
suspend fun updateFollowStatus(mangaID: String, followStatus: FollowStatus): Boolean {
|
||||
return withContext(Dispatchers.IO) {
|
||||
val response: Response =
|
||||
if (followStatus == FollowStatus.UNFOLLOWED) {
|
||||
client.newCall(
|
||||
GET(
|
||||
"$baseUrl/ajax/actions.ajax.php?function=manga_unfollow&id=$mangaID&type=$mangaID",
|
||||
headers,
|
||||
CacheControl.FORCE_NETWORK
|
||||
)
|
||||
)
|
||||
.execute()
|
||||
} else {
|
||||
val status = followStatus.int
|
||||
client.newCall(
|
||||
GET(
|
||||
"$baseUrl/ajax/actions.ajax.php?function=manga_follow&id=$mangaID&type=$status",
|
||||
headers,
|
||||
CacheControl.FORCE_NETWORK
|
||||
)
|
||||
)
|
||||
.execute()
|
||||
}
|
||||
|
||||
response.body!!.string().isEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updateReadingProgress(track: Track): Boolean {
|
||||
return withContext(Dispatchers.IO) {
|
||||
val mangaID = getMangaId(track.tracking_url)
|
||||
val formBody = FormBody.Builder()
|
||||
.add("chapter", track.last_chapter_read.toString())
|
||||
XLog.d("chapter to update %s", track.last_chapter_read.toString())
|
||||
val response = client.newCall(
|
||||
POST(
|
||||
"$baseUrl/ajax/actions.ajax.php?function=edit_progress&id=$mangaID",
|
||||
headers,
|
||||
formBody.build()
|
||||
)
|
||||
).execute()
|
||||
|
||||
val response2 = client.newCall(
|
||||
GET(
|
||||
"$baseUrl/ajax/actions.ajax.php?function=manga_rating&id=$mangaID&rating=${track.score.toInt()}",
|
||||
headers
|
||||
)
|
||||
)
|
||||
.execute()
|
||||
|
||||
response.body!!.string().isEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun updateRating(track: Track): Boolean {
|
||||
return withContext(Dispatchers.IO) {
|
||||
val mangaID = getMangaId(track.tracking_url)
|
||||
val response = client.newCall(
|
||||
GET(
|
||||
"$baseUrl/ajax/actions.ajax.php?function=manga_rating&id=$mangaID&rating=${track.score.toInt()}",
|
||||
headers
|
||||
)
|
||||
)
|
||||
.execute()
|
||||
|
||||
response.body!!.string().isEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* fetch all manga from all possible pages
|
||||
*/
|
||||
suspend fun fetchAllFollows(forceHd: Boolean): List<SManga> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
val listManga = mutableListOf<SManga>()
|
||||
loop@ for (i in 1..10000) {
|
||||
val response = client.newCall(followsListRequest(i))
|
||||
.execute()
|
||||
val mangasPage = followsParseMangaPage(response, forceHd)
|
||||
|
||||
if (mangasPage.mangas.isNotEmpty()) {
|
||||
listManga.addAll(mangasPage.mangas)
|
||||
}
|
||||
if (!mangasPage.hasNextPage) {
|
||||
break@loop
|
||||
}
|
||||
}
|
||||
listManga
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun fetchTrackingInfo(url: String): Track {
|
||||
return withContext(Dispatchers.IO) {
|
||||
val request = GET(
|
||||
"${MdUtil.baseUrl}${MdUtil.followsMangaApi}" + getMangaId(url),
|
||||
headers,
|
||||
CacheControl.FORCE_NETWORK
|
||||
)
|
||||
val response = client.newCall(request).execute()
|
||||
val track = followStatusParse(response)
|
||||
|
||||
track
|
||||
}
|
||||
}
|
||||
}
|
102
app/src/main/java/exh/md/handlers/MangaHandler.kt
Normal file
102
app/src/main/java/exh/md/handlers/MangaHandler.kt
Normal file
@ -0,0 +1,102 @@
|
||||
package exh.md.handlers
|
||||
|
||||
import com.elvishew.xlog.XLog
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.asObservableSuccess
|
||||
import eu.kanade.tachiyomi.source.model.SChapter
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import exh.md.utils.MdUtil
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import okhttp3.CacheControl
|
||||
import okhttp3.Headers
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import rx.Observable
|
||||
|
||||
class MangaHandler(val client: OkHttpClient, val headers: Headers, val langs: List<String>) {
|
||||
|
||||
// TODO make use of this
|
||||
suspend fun fetchMangaAndChapterDetails(manga: SManga): Pair<SManga, List<SChapter>> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
val response = client.newCall(apiRequest(manga)).execute()
|
||||
val parser = ApiMangaParser(langs)
|
||||
|
||||
val jsonData = response.body!!.string()
|
||||
if (response.code != 200) {
|
||||
XLog.e("error from MangaDex with response code ${response.code} \n body: \n$jsonData")
|
||||
throw Exception("Error from MangaDex Response code ${response.code} ")
|
||||
}
|
||||
|
||||
parser.parseToManga(manga, response).await()
|
||||
val chapterList = parser.chapterListParse(jsonData)
|
||||
Pair(
|
||||
manga,
|
||||
chapterList
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun getMangaIdFromChapterId(urlChapterId: String): Int {
|
||||
return withContext(Dispatchers.IO) {
|
||||
val request = GET(MdUtil.baseUrl + MdUtil.apiChapter + urlChapterId + MdUtil.apiChapterSuffix, headers, CacheControl.FORCE_NETWORK)
|
||||
val response = client.newCall(request).execute()
|
||||
ApiMangaParser(langs).chapterParseForMangaId(response)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun fetchMangaDetails(manga: SManga): SManga {
|
||||
return withContext(Dispatchers.IO) {
|
||||
val response = client.newCall(apiRequest(manga)).execute()
|
||||
ApiMangaParser(langs).parseToManga(manga, response).await()
|
||||
manga.apply {
|
||||
initialized = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun fetchMangaDetailsObservable(manga: SManga): Observable<SManga> {
|
||||
return client.newCall(apiRequest(manga))
|
||||
.asObservableSuccess()
|
||||
.flatMap {
|
||||
ApiMangaParser(langs).parseToManga(manga, it).andThen(
|
||||
Observable.just(
|
||||
manga.apply {
|
||||
initialized = true
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun fetchChapterListObservable(manga: SManga): Observable<List<SChapter>> {
|
||||
return client.newCall(apiRequest(manga))
|
||||
.asObservableSuccess()
|
||||
.map { response ->
|
||||
ApiMangaParser(langs).chapterListParse(response)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun fetchChapterList(manga: SManga): List<SChapter> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
val response = client.newCall(apiRequest(manga)).execute()
|
||||
ApiMangaParser(langs).chapterListParse(response)
|
||||
}
|
||||
}
|
||||
|
||||
fun fetchRandomMangaId(): Observable<String> {
|
||||
return client.newCall(randomMangaRequest())
|
||||
.asObservableSuccess()
|
||||
.map { response ->
|
||||
ApiMangaParser(langs).randomMangaIdParse(response)
|
||||
}
|
||||
}
|
||||
|
||||
private fun randomMangaRequest(): Request {
|
||||
return GET(MdUtil.baseUrl + MdUtil.randMangaPage)
|
||||
}
|
||||
|
||||
private fun apiRequest(manga: SManga): Request {
|
||||
return GET(MdUtil.baseUrl + MdUtil.apiManga + MdUtil.getMangaId(manga.url), headers, CacheControl.FORCE_NETWORK)
|
||||
}
|
||||
}
|
105
app/src/main/java/exh/md/handlers/MangaPlusHandler.kt
Normal file
105
app/src/main/java/exh/md/handlers/MangaPlusHandler.kt
Normal file
@ -0,0 +1,105 @@
|
||||
package exh.md.handlers
|
||||
|
||||
import MangaPlusSerializer
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import java.util.UUID
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.protobuf.ProtoBuf
|
||||
import okhttp3.Headers
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.MediaType.Companion.toMediaTypeOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import okhttp3.ResponseBody.Companion.toResponseBody
|
||||
|
||||
class MangaPlusHandler(currentClient: OkHttpClient) {
|
||||
val baseUrl = "https://jumpg-webapi.tokyo-cdn.com/api"
|
||||
val headers = Headers.Builder()
|
||||
.add("Origin", WEB_URL)
|
||||
.add("Referer", WEB_URL)
|
||||
.add("User-Agent", USER_AGENT)
|
||||
.add("SESSION-TOKEN", UUID.randomUUID().toString()).build()
|
||||
|
||||
val client: OkHttpClient = currentClient.newBuilder()
|
||||
.addInterceptor { imageIntercept(it) }
|
||||
.build()
|
||||
|
||||
@ExperimentalSerializationApi
|
||||
fun fetchPageList(chapterId: String): List<Page> {
|
||||
val response = client.newCall(pageListRequest(chapterId)).execute()
|
||||
return pageListParse(response)
|
||||
}
|
||||
|
||||
private fun pageListRequest(chapterId: String): Request {
|
||||
return GET(
|
||||
"$baseUrl/manga_viewer?chapter_id=$chapterId&split=yes&img_quality=high",
|
||||
headers
|
||||
)
|
||||
}
|
||||
|
||||
@ExperimentalSerializationApi
|
||||
private fun pageListParse(response: Response): List<Page> {
|
||||
val result = ProtoBuf.decodeFromByteArray(MangaPlusSerializer, response.body!!.bytes())
|
||||
|
||||
if (result.success == null) {
|
||||
throw Exception("error getting images")
|
||||
}
|
||||
|
||||
return result.success.mangaViewer!!.pages
|
||||
.mapNotNull { it.page }
|
||||
.mapIndexed { i, page ->
|
||||
val encryptionKey =
|
||||
if (page.encryptionKey == null) "" else "&encryptionKey=${page.encryptionKey}"
|
||||
Page(i, "", "${page.imageUrl}$encryptionKey")
|
||||
}
|
||||
}
|
||||
|
||||
private fun imageIntercept(chain: Interceptor.Chain): Response {
|
||||
var request = chain.request()
|
||||
|
||||
if (!request.url.queryParameterNames.contains("encryptionKey")) {
|
||||
return chain.proceed(request)
|
||||
}
|
||||
|
||||
val encryptionKey = request.url.queryParameter("encryptionKey")!!
|
||||
|
||||
// Change the url and remove the encryptionKey to avoid detection.
|
||||
val newUrl = request.url.newBuilder().removeAllQueryParameters("encryptionKey").build()
|
||||
request = request.newBuilder().url(newUrl).build()
|
||||
|
||||
val response = chain.proceed(request)
|
||||
|
||||
val image = decodeImage(encryptionKey, response.body!!.bytes())
|
||||
|
||||
val body = image.toResponseBody("image/jpeg".toMediaTypeOrNull())
|
||||
return response.newBuilder().body(body).build()
|
||||
}
|
||||
|
||||
private fun decodeImage(encryptionKey: String, image: ByteArray): ByteArray {
|
||||
val keyStream = HEX_GROUP
|
||||
.findAll(encryptionKey)
|
||||
.map { it.groupValues[1].toInt(16) }
|
||||
.toList()
|
||||
|
||||
val content = image
|
||||
.map { it.toInt() }
|
||||
.toMutableList()
|
||||
|
||||
val blockSizeInBytes = keyStream.size
|
||||
|
||||
for ((i, value) in content.iterator().withIndex()) {
|
||||
content[i] = value xor keyStream[i % blockSizeInBytes]
|
||||
}
|
||||
|
||||
return ByteArray(content.size) { pos -> content[pos].toByte() }
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val WEB_URL = "https://mangaplus.shueisha.co.jp"
|
||||
private const val USER_AGENT =
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/69.0.3497.92 Safari/537.36"
|
||||
private val HEX_GROUP = "(.{1,2})".toRegex()
|
||||
}
|
||||
}
|
37
app/src/main/java/exh/md/handlers/PageHandler.kt
Normal file
37
app/src/main/java/exh/md/handlers/PageHandler.kt
Normal file
@ -0,0 +1,37 @@
|
||||
package exh.md.handlers
|
||||
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.asObservableSuccess
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.source.model.SChapter
|
||||
import exh.md.utils.MdUtil
|
||||
import okhttp3.CacheControl
|
||||
import okhttp3.Headers
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import rx.Observable
|
||||
|
||||
// Unused, kept for reference todo
|
||||
class PageHandler(val client: OkHttpClient, val headers: Headers, private val imageServer: String, val dataSaver: String?) {
|
||||
|
||||
fun fetchPageList(chapter: SChapter): Observable<List<Page>> {
|
||||
if (chapter.scanlator.equals("MangaPlus")) {
|
||||
return client.newCall(pageListRequest(chapter))
|
||||
.asObservableSuccess()
|
||||
.map { response ->
|
||||
val chapterId = ApiChapterParser().externalParse(response)
|
||||
MangaPlusHandler(client).fetchPageList(chapterId)
|
||||
}
|
||||
}
|
||||
return client.newCall(pageListRequest(chapter))
|
||||
.asObservableSuccess()
|
||||
.map { response ->
|
||||
ApiChapterParser().pageListParse(response)
|
||||
}
|
||||
}
|
||||
|
||||
private fun pageListRequest(chapter: SChapter): Request {
|
||||
val chpUrl = chapter.url.substringBefore(MdUtil.apiChapterSuffix)
|
||||
return GET("${MdUtil.baseUrl}${chpUrl}${MdUtil.apiChapterSuffix}&server=$imageServer&saver=$dataSaver", headers, CacheControl.FORCE_NETWORK)
|
||||
}
|
||||
}
|
71
app/src/main/java/exh/md/handlers/PopularHandler.kt
Normal file
71
app/src/main/java/exh/md/handlers/PopularHandler.kt
Normal file
@ -0,0 +1,71 @@
|
||||
package exh.md.handlers
|
||||
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.asObservableSuccess
|
||||
import eu.kanade.tachiyomi.source.model.MangasPage
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import eu.kanade.tachiyomi.util.asJsoup
|
||||
import exh.md.utils.MdUtil
|
||||
import exh.md.utils.setMDUrlWithoutDomain
|
||||
import okhttp3.CacheControl
|
||||
import okhttp3.Headers
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.jsoup.nodes.Element
|
||||
import rx.Observable
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
|
||||
// Unused, kept for reference todo
|
||||
/**
|
||||
* Returns the latest manga from the updates url since it actually respects the users settings
|
||||
*/
|
||||
class PopularHandler(val client: OkHttpClient, private val headers: Headers) {
|
||||
|
||||
private val preferences: PreferencesHelper by injectLazy()
|
||||
|
||||
fun fetchPopularManga(page: Int): Observable<MangasPage> {
|
||||
return client.newCall(popularMangaRequest(page))
|
||||
.asObservableSuccess()
|
||||
.map { response ->
|
||||
popularMangaParse(response)
|
||||
}
|
||||
}
|
||||
|
||||
private fun popularMangaRequest(page: Int): Request {
|
||||
return GET("${MdUtil.baseUrl}/updates/$page/", headers, CacheControl.FORCE_NETWORK)
|
||||
}
|
||||
|
||||
private fun popularMangaParse(response: Response): MangasPage {
|
||||
val document = response.asJsoup()
|
||||
|
||||
val mangas = document.select(popularMangaSelector).map { element ->
|
||||
popularMangaFromElement(element)
|
||||
}.distinct()
|
||||
|
||||
val hasNextPage = popularMangaNextPageSelector.let { selector ->
|
||||
document.select(selector).first()
|
||||
} != null
|
||||
|
||||
return MangasPage(mangas, hasNextPage)
|
||||
}
|
||||
|
||||
private fun popularMangaFromElement(element: Element): SManga {
|
||||
val manga = SManga.create()
|
||||
element.select("a.manga_title").first().let {
|
||||
val url = MdUtil.modifyMangaUrl(it.attr("href"))
|
||||
manga.setMDUrlWithoutDomain(url)
|
||||
manga.title = it.text().trim()
|
||||
}
|
||||
|
||||
manga.thumbnail_url = MdUtil.formThumbUrl(manga.url, preferences.mangaDexLowQualityCovers().get())
|
||||
|
||||
return manga
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val popularMangaSelector = "tr a.manga_title"
|
||||
const val popularMangaNextPageSelector = ".pagination li:not(.disabled) span[title*=last page]:not(disabled)"
|
||||
}
|
||||
}
|
216
app/src/main/java/exh/md/handlers/SearchHandler.kt
Normal file
216
app/src/main/java/exh/md/handlers/SearchHandler.kt
Normal file
@ -0,0 +1,216 @@
|
||||
package exh.md.handlers
|
||||
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.network.GET
|
||||
import eu.kanade.tachiyomi.network.asObservableSuccess
|
||||
import eu.kanade.tachiyomi.source.model.FilterList
|
||||
import eu.kanade.tachiyomi.source.model.MangasPage
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import eu.kanade.tachiyomi.util.asJsoup
|
||||
import exh.md.utils.MdUtil
|
||||
import exh.md.utils.setMDUrlWithoutDomain
|
||||
import okhttp3.CacheControl
|
||||
import okhttp3.Headers
|
||||
import okhttp3.HttpUrl.Companion.toHttpUrlOrNull
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.Request
|
||||
import okhttp3.Response
|
||||
import org.jsoup.nodes.Element
|
||||
import rx.Observable
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
|
||||
// Unused, kept for reference todo
|
||||
class SearchHandler(val client: OkHttpClient, private val headers: Headers, val langs: List<String>) {
|
||||
|
||||
private val preferences: PreferencesHelper by injectLazy()
|
||||
|
||||
fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
|
||||
return when {
|
||||
query.startsWith(PREFIX_ID_SEARCH) -> {
|
||||
val realQuery = query.removePrefix(PREFIX_ID_SEARCH)
|
||||
client.newCall(searchMangaByIdRequest(realQuery))
|
||||
.asObservableSuccess()
|
||||
.map { response ->
|
||||
val details = SManga.create()
|
||||
details.url = "/manga/$realQuery/"
|
||||
ApiMangaParser(langs).parseToManga(details, response).await()
|
||||
MangasPage(listOf(details), false)
|
||||
}
|
||||
}
|
||||
query.startsWith(PREFIX_GROUP_SEARCH) -> {
|
||||
val realQuery = query.removePrefix(PREFIX_GROUP_SEARCH)
|
||||
client.newCall(searchMangaByGroupRequest(realQuery))
|
||||
.asObservableSuccess()
|
||||
.map { response ->
|
||||
response.asJsoup().select(groupSelector).firstOrNull()?.attr("abs:href")
|
||||
?.let {
|
||||
searchMangaParse(client.newCall(GET("$it/manga/0", headers)).execute())
|
||||
}
|
||||
?: MangasPage(emptyList(), false)
|
||||
}
|
||||
}
|
||||
else -> {
|
||||
client.newCall(searchMangaRequest(page, query, filters))
|
||||
.asObservableSuccess()
|
||||
.map { response ->
|
||||
searchMangaParse(response)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun searchMangaParse(response: Response): MangasPage {
|
||||
val document = response.asJsoup()
|
||||
|
||||
val mangas = document.select(searchMangaSelector).map { element ->
|
||||
searchMangaFromElement(element)
|
||||
}
|
||||
|
||||
val hasNextPage = searchMangaNextPageSelector.let { selector ->
|
||||
document.select(selector).first()
|
||||
} != null
|
||||
|
||||
return MangasPage(mangas, hasNextPage)
|
||||
}
|
||||
|
||||
private fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
|
||||
val tags = mutableListOf<String>()
|
||||
val statuses = mutableListOf<String>()
|
||||
val demographics = mutableListOf<String>()
|
||||
|
||||
// Do traditional search
|
||||
val url = "${MdUtil.baseUrl}/?page=search".toHttpUrlOrNull()!!.newBuilder()
|
||||
.addQueryParameter("p", page.toString())
|
||||
.addQueryParameter("title", query.replace(WHITESPACE_REGEX, " "))
|
||||
|
||||
filters.forEach { filter ->
|
||||
when (filter) {
|
||||
is FilterHandler.TextField -> url.addQueryParameter(filter.key, filter.state)
|
||||
is FilterHandler.DemographicList -> {
|
||||
filter.state.forEach { demographic ->
|
||||
if (demographic.state) {
|
||||
demographics.add(demographic.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
is FilterHandler.PublicationStatusList -> {
|
||||
filter.state.forEach { status ->
|
||||
if (status.state) {
|
||||
statuses.add(status.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
is FilterHandler.OriginalLanguage -> {
|
||||
if (filter.state != 0) {
|
||||
val number: String =
|
||||
FilterHandler.sourceLang().first { it -> it.first == filter.values[filter.state] }
|
||||
.second
|
||||
url.addQueryParameter("lang_id", number)
|
||||
}
|
||||
}
|
||||
is FilterHandler.TagInclusionMode -> {
|
||||
url.addQueryParameter("tag_mode_inc", arrayOf("all", "any")[filter.state])
|
||||
}
|
||||
is FilterHandler.TagExclusionMode -> {
|
||||
url.addQueryParameter("tag_mode_exc", arrayOf("all", "any")[filter.state])
|
||||
}
|
||||
is FilterHandler.ContentList -> {
|
||||
filter.state.forEach { content ->
|
||||
if (content.isExcluded()) {
|
||||
tags.add("-${content.id}")
|
||||
} else if (content.isIncluded()) {
|
||||
tags.add(content.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
is FilterHandler.FormatList -> {
|
||||
filter.state.forEach { format ->
|
||||
if (format.isExcluded()) {
|
||||
tags.add("-${format.id}")
|
||||
} else if (format.isIncluded()) {
|
||||
tags.add(format.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
is FilterHandler.GenreList -> {
|
||||
filter.state.forEach { genre ->
|
||||
if (genre.isExcluded()) {
|
||||
tags.add("-${genre.id}")
|
||||
} else if (genre.isIncluded()) {
|
||||
tags.add(genre.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
is FilterHandler.ThemeList -> {
|
||||
filter.state.forEach { theme ->
|
||||
if (theme.isExcluded()) {
|
||||
tags.add("-${theme.id}")
|
||||
} else if (theme.isIncluded()) {
|
||||
tags.add(theme.id)
|
||||
}
|
||||
}
|
||||
}
|
||||
is FilterHandler.SortFilter -> {
|
||||
if (filter.state != null) {
|
||||
val sortables = FilterHandler.sortables()
|
||||
if (filter.state!!.ascending) {
|
||||
url.addQueryParameter(
|
||||
"s",
|
||||
sortables[filter.state!!.index].second.toString()
|
||||
)
|
||||
} else {
|
||||
url.addQueryParameter(
|
||||
"s",
|
||||
sortables[filter.state!!.index].third.toString()
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// Manually append genres list to avoid commas being encoded
|
||||
var urlToUse = url.toString()
|
||||
if (demographics.isNotEmpty()) {
|
||||
urlToUse += "&demos=" + demographics.joinToString(",")
|
||||
}
|
||||
if (statuses.isNotEmpty()) {
|
||||
urlToUse += "&statuses=" + statuses.joinToString(",")
|
||||
}
|
||||
if (tags.isNotEmpty()) {
|
||||
urlToUse += "&tags=" + tags.joinToString(",")
|
||||
}
|
||||
|
||||
return GET(urlToUse, headers, CacheControl.FORCE_NETWORK)
|
||||
}
|
||||
|
||||
private fun searchMangaFromElement(element: Element): SManga {
|
||||
val manga = SManga.create()
|
||||
element.select("a.manga_title").first().let {
|
||||
val url = MdUtil.modifyMangaUrl(it.attr("href"))
|
||||
manga.setMDUrlWithoutDomain(url)
|
||||
manga.title = it.text().trim()
|
||||
}
|
||||
|
||||
manga.thumbnail_url = MdUtil.formThumbUrl(manga.url, preferences.mangaDexLowQualityCovers().get())
|
||||
|
||||
return manga
|
||||
}
|
||||
|
||||
private fun searchMangaByIdRequest(id: String): Request {
|
||||
return GET(MdUtil.baseUrl + MdUtil.apiManga + id, headers, CacheControl.FORCE_NETWORK)
|
||||
}
|
||||
|
||||
private fun searchMangaByGroupRequest(group: String): Request {
|
||||
return GET(MdUtil.groupSearchUrl + group, headers, CacheControl.FORCE_NETWORK)
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val PREFIX_ID_SEARCH = "id:"
|
||||
const val PREFIX_GROUP_SEARCH = "group:"
|
||||
val WHITESPACE_REGEX = "\\s".toRegex()
|
||||
const val searchMangaNextPageSelector =
|
||||
".pagination li:not(.disabled) span[title*=last page]:not(disabled)"
|
||||
const val searchMangaSelector = "div.manga-entry"
|
||||
const val groupSelector = ".table > tbody:nth-child(2) > tr:nth-child(1) > td:nth-child(2) > a"
|
||||
}
|
||||
}
|
47
app/src/main/java/exh/md/handlers/SimilarHandler.kt
Normal file
47
app/src/main/java/exh/md/handlers/SimilarHandler.kt
Normal file
@ -0,0 +1,47 @@
|
||||
package exh.md.handlers
|
||||
|
||||
// todo make this work
|
||||
/*import eu.kanade.tachiyomi.data.database.DatabaseHelper
|
||||
import eu.kanade.tachiyomi.data.database.models.Manga
|
||||
import eu.kanade.tachiyomi.data.database.models.MangaSimilarImpl
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.source.model.MangasPage
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import exh.md.utils.MdUtil
|
||||
import rx.Observable
|
||||
import uy.kohesive.injekt.Injekt
|
||||
import uy.kohesive.injekt.api.get
|
||||
|
||||
class SimilarHandler(val preferences: PreferencesHelper) {
|
||||
|
||||
*//**
|
||||
* fetch our similar mangas
|
||||
*//*
|
||||
fun fetchSimilar(manga: Manga): Observable<MangasPage> {
|
||||
|
||||
// Parse the Mangadex id from the URL
|
||||
val mangaid = MdUtil.getMangaId(manga.url).toLong()
|
||||
|
||||
val lowQualityCovers = preferences.mangaDexLowQualityCovers().get()
|
||||
|
||||
// Get our current database
|
||||
val db = Injekt.get<DatabaseHelper>()
|
||||
val similarMangaDb = db.getSimilar(mangaid).executeAsBlocking() ?: return Observable.just(MangasPage(mutableListOf(), false))
|
||||
|
||||
// Check if we have a result
|
||||
|
||||
val similarMangaTitles = similarMangaDb.matched_titles.split(MangaSimilarImpl.DELIMITER)
|
||||
val similarMangaIds = similarMangaDb.matched_ids.split(MangaSimilarImpl.DELIMITER)
|
||||
|
||||
val similarMangas = similarMangaIds.mapIndexed { index, similarId ->
|
||||
SManga.create().apply {
|
||||
title = similarMangaTitles[index]
|
||||
url = "/manga/$similarId/"
|
||||
thumbnail_url = MdUtil.formThumbUrl(url, lowQualityCovers)
|
||||
}
|
||||
}
|
||||
|
||||
// Return the matches
|
||||
return Observable.just(MangasPage(similarMangas, false))
|
||||
}
|
||||
}*/
|
@ -0,0 +1,74 @@
|
||||
package exh.md.handlers.serializers
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class ApiMangaSerializer(
|
||||
val chapter: Map<String, ChapterSerializer>? = null,
|
||||
val manga: MangaSerializer,
|
||||
val status: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MangaSerializer(
|
||||
val artist: String,
|
||||
val author: String,
|
||||
val cover_url: String,
|
||||
val description: String,
|
||||
val genres: List<Int>,
|
||||
val hentai: Int,
|
||||
val lang_flag: String,
|
||||
val lang_name: String,
|
||||
val last_chapter: String? = null,
|
||||
val links: LinksSerializer? = null,
|
||||
val rating: RatingSerializer? = null,
|
||||
val status: Int,
|
||||
val title: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class LinksSerializer(
|
||||
val al: String? = null,
|
||||
val amz: String? = null,
|
||||
val ap: String? = null,
|
||||
val engtl: String? = null,
|
||||
val kt: String? = null,
|
||||
val mal: String? = null,
|
||||
val mu: String? = null,
|
||||
val raw: String? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class RatingSerializer(
|
||||
val bayesian: String? = null,
|
||||
val mean: String? = null,
|
||||
val users: String? = null
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ChapterSerializer(
|
||||
val volume: String? = null,
|
||||
val chapter: String? = null,
|
||||
val title: String? = null,
|
||||
val lang_code: String,
|
||||
val group_id: Int? = null,
|
||||
val group_name: String? = null,
|
||||
val group_id_2: Int? = null,
|
||||
val group_name_2: String? = null,
|
||||
val group_id_3: Int? = null,
|
||||
val group_name_3: String? = null,
|
||||
val timestamp: Long
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class CoversResult(
|
||||
val covers: List<String> = emptyList(),
|
||||
val status: String
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class ImageReportResult(
|
||||
val url: String,
|
||||
val success: Boolean,
|
||||
val bytes: Int?
|
||||
)
|
@ -0,0 +1,17 @@
|
||||
package exh.md.handlers.serializers
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class FollowsPageResult(
|
||||
val result: List<Result> = emptyList()
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class Result(
|
||||
val title: String,
|
||||
val chapter: String,
|
||||
val follow_type: Int,
|
||||
val manga_id: Int,
|
||||
val volume: String
|
||||
)
|
@ -0,0 +1,136 @@
|
||||
import kotlinx.serialization.ExperimentalSerializationApi
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.Serializer
|
||||
import kotlinx.serialization.protobuf.ProtoNumber
|
||||
|
||||
@ExperimentalSerializationApi
|
||||
@Serializer(forClass = MangaPlusResponse::class)
|
||||
object MangaPlusSerializer
|
||||
|
||||
@ExperimentalSerializationApi
|
||||
@Serializable
|
||||
data class MangaPlusResponse(
|
||||
@ProtoNumber(1) val success: SuccessResult? = null,
|
||||
@ProtoNumber(2) val error: ErrorResult? = null
|
||||
)
|
||||
|
||||
@ExperimentalSerializationApi
|
||||
@Serializable
|
||||
data class ErrorResult(
|
||||
@ProtoNumber(1) val action: Action,
|
||||
@ProtoNumber(2) val englishPopup: Popup,
|
||||
@ProtoNumber(3) val spanishPopup: Popup
|
||||
)
|
||||
|
||||
enum class Action { DEFAULT, UNAUTHORIZED, MAINTAINENCE, GEOIP_BLOCKING }
|
||||
|
||||
@ExperimentalSerializationApi
|
||||
@Serializable
|
||||
data class Popup(
|
||||
@ProtoNumber(1) val subject: String,
|
||||
@ProtoNumber(2) val body: String
|
||||
)
|
||||
|
||||
@ExperimentalSerializationApi
|
||||
@Serializable
|
||||
data class SuccessResult(
|
||||
@ProtoNumber(1) val isFeaturedUpdated: Boolean? = false,
|
||||
@ProtoNumber(5) val allTitlesView: AllTitlesView? = null,
|
||||
@ProtoNumber(6) val titleRankingView: TitleRankingView? = null,
|
||||
@ProtoNumber(8) val titleDetailView: TitleDetailView? = null,
|
||||
@ProtoNumber(10) val mangaViewer: MangaViewer? = null,
|
||||
@ProtoNumber(11) val webHomeView: WebHomeView? = null
|
||||
)
|
||||
|
||||
@ExperimentalSerializationApi
|
||||
@Serializable
|
||||
data class TitleRankingView(@ProtoNumber(1) val titles: List<Title> = emptyList())
|
||||
|
||||
@ExperimentalSerializationApi
|
||||
@Serializable
|
||||
data class AllTitlesView(@ProtoNumber(1) val titles: List<Title> = emptyList())
|
||||
|
||||
@ExperimentalSerializationApi
|
||||
@Serializable
|
||||
data class WebHomeView(@ProtoNumber(2) val groups: List<UpdatedTitleGroup> = emptyList())
|
||||
|
||||
@ExperimentalSerializationApi
|
||||
@Serializable
|
||||
data class TitleDetailView(
|
||||
@ProtoNumber(1) val title: Title,
|
||||
@ProtoNumber(2) val titleImageUrl: String,
|
||||
@ProtoNumber(3) val overview: String,
|
||||
@ProtoNumber(4) val backgroundImageUrl: String,
|
||||
@ProtoNumber(5) val nextTimeStamp: Int = 0,
|
||||
@ProtoNumber(6) val updateTiming: UpdateTiming? = UpdateTiming.DAY,
|
||||
@ProtoNumber(7) val viewingPeriodDescription: String = "",
|
||||
@ProtoNumber(9) val firstChapterList: List<Chapter> = emptyList(),
|
||||
@ProtoNumber(10) val lastChapterList: List<Chapter> = emptyList(),
|
||||
@ProtoNumber(14) val isSimulReleased: Boolean = true,
|
||||
@ProtoNumber(17) val chaptersDescending: Boolean = true
|
||||
)
|
||||
|
||||
enum class UpdateTiming { NOT_REGULARLY, MONDAY, TUESDAY, WEDNESDAY, THURSDAY, FRIDAY, SATURDAY, SUNDAY, DAY }
|
||||
|
||||
@ExperimentalSerializationApi
|
||||
@Serializable
|
||||
data class MangaViewer(@ProtoNumber(1) val pages: List<MangaPlusPage> = emptyList())
|
||||
|
||||
@ExperimentalSerializationApi
|
||||
@Serializable
|
||||
data class Title(
|
||||
@ProtoNumber(1) val titleId: Int,
|
||||
@ProtoNumber(2) val name: String,
|
||||
@ProtoNumber(3) val author: String,
|
||||
@ProtoNumber(4) val portraitImageUrl: String,
|
||||
@ProtoNumber(5) val landscapeImageUrl: String,
|
||||
@ProtoNumber(6) val viewCount: Int,
|
||||
@ProtoNumber(7) val language: Language? = Language.ENGLISH
|
||||
)
|
||||
|
||||
@ExperimentalSerializationApi
|
||||
@Serializable
|
||||
enum class Language(val id: Int) {
|
||||
@ProtoNumber(0)
|
||||
ENGLISH(0),
|
||||
|
||||
@ProtoNumber(1)
|
||||
SPANISH(1)
|
||||
}
|
||||
|
||||
@ExperimentalSerializationApi
|
||||
@Serializable
|
||||
data class UpdatedTitleGroup(
|
||||
@ProtoNumber(1) val groupName: String,
|
||||
@ProtoNumber(2) val titles: List<UpdatedTitle> = emptyList()
|
||||
)
|
||||
|
||||
@ExperimentalSerializationApi
|
||||
@Serializable
|
||||
data class UpdatedTitle(
|
||||
@ProtoNumber(1) val title: Title? = null
|
||||
)
|
||||
|
||||
@ExperimentalSerializationApi
|
||||
@Serializable
|
||||
data class Chapter(
|
||||
@ProtoNumber(1) val titleId: Int,
|
||||
@ProtoNumber(2) val chapterId: Int,
|
||||
@ProtoNumber(3) val name: String,
|
||||
@ProtoNumber(4) val subTitle: String? = null,
|
||||
@ProtoNumber(6) val startTimeStamp: Int,
|
||||
@ProtoNumber(7) val endTimeStamp: Int
|
||||
)
|
||||
|
||||
@ExperimentalSerializationApi
|
||||
@Serializable
|
||||
data class MangaPlusPage(@ProtoNumber(1) val page: MangaPage? = null)
|
||||
|
||||
@ExperimentalSerializationApi
|
||||
@Serializable
|
||||
data class MangaPage(
|
||||
@ProtoNumber(1) val imageUrl: String,
|
||||
@ProtoNumber(2) val width: Int,
|
||||
@ProtoNumber(3) val height: Int,
|
||||
@ProtoNumber(5) val encryptionKey: String? = null
|
||||
)
|
15
app/src/main/java/exh/md/utils/FollowStatus.kt
Normal file
15
app/src/main/java/exh/md/utils/FollowStatus.kt
Normal file
@ -0,0 +1,15 @@
|
||||
package exh.md.utils
|
||||
|
||||
enum class FollowStatus(val int: Int) {
|
||||
UNFOLLOWED(0),
|
||||
READING(1),
|
||||
COMPLETED(2),
|
||||
ON_HOLD(3),
|
||||
PLAN_TO_READ(4),
|
||||
DROPPED(5),
|
||||
RE_READING(6);
|
||||
|
||||
companion object {
|
||||
fun fromInt(value: Int): FollowStatus? = values().find { it.int == value }
|
||||
}
|
||||
}
|
45
app/src/main/java/exh/md/utils/MdLang.kt
Normal file
45
app/src/main/java/exh/md/utils/MdLang.kt
Normal file
@ -0,0 +1,45 @@
|
||||
package exh.md.utils
|
||||
|
||||
enum class MdLang(val lang: String, val dexLang: String, val langId: Int) {
|
||||
English("en", "gb", 1),
|
||||
Japanese("ja", "jp", 2),
|
||||
Polish("pl", "pl", 3),
|
||||
SerboCroatian("sh", "rs", 4),
|
||||
Dutch("nl", "nl", 5),
|
||||
Italian("it", "it", 6),
|
||||
Russian("ru", "ru", 7),
|
||||
German("de", "de", 8),
|
||||
Hungarian("hu", "hu", 9),
|
||||
French("fr", "fr", 10),
|
||||
Finnish("fi", "fi", 11),
|
||||
Vietnamese("vi", "vn", 12),
|
||||
Greek("el", "gr", 13),
|
||||
Bulgarian("bg", "bg", 14),
|
||||
Spanish("es", "es", 15),
|
||||
PortugeseBrazilian("pt-BR", "br", 16),
|
||||
Portuguese("pt", "pt", 17),
|
||||
Swedish("sv", "se", 18),
|
||||
Arabic("ar", "sa", 19),
|
||||
Danish("da", "dk", 20),
|
||||
ChineseSimplifed("zh-Hans", "cn", 21),
|
||||
Bengali("bn", "bd", 22),
|
||||
Romanian("ro", "ro", 23),
|
||||
Czech("cs", "cz", 24),
|
||||
Mongolian("mn", "mn", 25),
|
||||
Turkish("tr", "tr", 26),
|
||||
Indonesian("id", "id", 27),
|
||||
Korean("ko", "kr", 28),
|
||||
SpanishLTAM("es-419", "mx", 29),
|
||||
Persian("fa", "ir", 30),
|
||||
Malay("ms", "my", 31),
|
||||
Thai("th", "th", 32),
|
||||
Catalan("ca", "ct", 33),
|
||||
Filipino("fil", "ph", 34),
|
||||
ChineseTraditional("zh-Hant", "hk", 35),
|
||||
Ukrainian("uk", "ua", 36),
|
||||
Burmese("my", "mm", 37),
|
||||
Lithuanian("lt", "il", 38),
|
||||
Hebrew("he", "il", 39),
|
||||
Hindi("hi", "in", 40),
|
||||
Norwegian("no", "no", 42)
|
||||
}
|
248
app/src/main/java/exh/md/utils/MdUtil.kt
Normal file
248
app/src/main/java/exh/md/utils/MdUtil.kt
Normal file
@ -0,0 +1,248 @@
|
||||
package exh.md.utils
|
||||
|
||||
import eu.kanade.tachiyomi.source.model.SChapter
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import java.net.URI
|
||||
import java.net.URISyntaxException
|
||||
import kotlin.math.floor
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.jsoup.parser.Parser
|
||||
|
||||
class MdUtil {
|
||||
|
||||
companion object {
|
||||
const val cdnUrl = "https://mangadex.org" // "https://s0.mangadex.org"
|
||||
const val baseUrl = "https://mangadex.org"
|
||||
const val randMangaPage = "/manga/"
|
||||
const val apiManga = "/api/manga/"
|
||||
const val apiChapter = "/api/chapter/"
|
||||
const val apiChapterSuffix = "?mark_read=0"
|
||||
const val groupSearchUrl = "$baseUrl/groups/0/1/"
|
||||
const val followsAllApi = "/api/?type=manga_follows"
|
||||
const val followsMangaApi = "/api/?type=manga_follows&manga_id="
|
||||
const val coversApi = "/api/index.php?type=covers&id="
|
||||
const val reportUrl = "https://api.mangadex.network/report"
|
||||
const val imageUrl = "$baseUrl/data"
|
||||
|
||||
val jsonParser = Json {
|
||||
isLenient = true
|
||||
ignoreUnknownKeys = true
|
||||
allowSpecialFloatingPointValues = true
|
||||
useArrayPolymorphism = true
|
||||
prettyPrint = true
|
||||
}
|
||||
|
||||
private const
|
||||
val scanlatorSeparator = " & "
|
||||
|
||||
val validOneShotFinalChapters = listOf("0", "1")
|
||||
|
||||
val englishDescriptionTags = listOf(
|
||||
"[b][u]English:",
|
||||
"[b][u]English",
|
||||
"[English]:",
|
||||
"[B][ENG][/B]"
|
||||
)
|
||||
|
||||
val descriptionLanguages = listOf(
|
||||
"Russian / Русский",
|
||||
"[u]Russian",
|
||||
"[b][u]Russian",
|
||||
"[RUS]",
|
||||
"Russian / Русский",
|
||||
"Russian/Русский:",
|
||||
"Russia/Русское",
|
||||
"Русский",
|
||||
"RUS:",
|
||||
"[b][u]German / Deutsch",
|
||||
"German/Deutsch:",
|
||||
"Español / Spanish",
|
||||
"Spanish / Español",
|
||||
"Spanish / Espa & ntilde; ol",
|
||||
"Spanish / Español",
|
||||
"[b][u]Spanish",
|
||||
"[Español]:",
|
||||
"[b] Spanish: [/ b]",
|
||||
"Spanish/Español",
|
||||
"Español / Spanish",
|
||||
"Italian / Italiano",
|
||||
"Pasta-Pizza-Mandolino/Italiano",
|
||||
"Polish / polski",
|
||||
"Polish / Polski",
|
||||
"Polish Summary / Polski Opis",
|
||||
"Polski",
|
||||
"Portuguese (BR) / Português",
|
||||
"Portuguese / Português",
|
||||
"Português / Portuguese",
|
||||
"Portuguese / Portugu",
|
||||
"Portuguese / Português",
|
||||
"Português",
|
||||
"Portuguese (BR) / Portugu & ecirc;",
|
||||
"Portuguese (BR) / Portuguê",
|
||||
"[PTBR]",
|
||||
"Résume Français",
|
||||
"Résumé Français",
|
||||
"[b][u]French",
|
||||
"French / Français",
|
||||
"Français",
|
||||
"[hr]Fr:",
|
||||
"French - Français:",
|
||||
"Turkish / Türkçe",
|
||||
"Turkish/Türkçe",
|
||||
"[b][u]Chinese",
|
||||
"Arabic / العربية",
|
||||
"العربية",
|
||||
"[hr]TH",
|
||||
"[b][u]Vietnamese",
|
||||
"[b]Links:",
|
||||
"[b]Link[/b]",
|
||||
"Links:",
|
||||
"[b]External Links"
|
||||
)
|
||||
|
||||
// guess the thumbnail url is .jpg this has a ~80% success rate
|
||||
fun formThumbUrl(mangaUrl: String, lowQuality: Boolean): String {
|
||||
var ext = ".jpg"
|
||||
if (lowQuality) {
|
||||
ext = ".thumb$ext"
|
||||
}
|
||||
return cdnUrl + "/images/manga/" + getMangaId(mangaUrl) + ext
|
||||
}
|
||||
|
||||
// Get the ID from the manga url
|
||||
fun getMangaId(url: String): String {
|
||||
val lastSection = url.trimEnd('/').substringAfterLast("/")
|
||||
return if (lastSection.toIntOrNull() != null) {
|
||||
lastSection
|
||||
} else {
|
||||
// this occurs if person has manga from before that had the id/name/
|
||||
url.trimEnd('/').substringBeforeLast("/").substringAfterLast("/")
|
||||
}
|
||||
}
|
||||
|
||||
fun getChapterId(url: String) = url.substringBeforeLast(apiChapterSuffix).substringAfterLast("/")
|
||||
|
||||
// creates the manga url from the browse for the api
|
||||
fun modifyMangaUrl(url: String): String =
|
||||
url.replace("/title/", "/manga/").substringBeforeLast("/") + "/"
|
||||
|
||||
// Removes the ?timestamp from image urls
|
||||
fun removeTimeParamUrl(url: String): String = url.substringBeforeLast("?")
|
||||
|
||||
fun cleanString(string: String): String {
|
||||
val bbRegex =
|
||||
"""\[(\w+)[^]]*](.*?)\[/\1]""".toRegex()
|
||||
var intermediate = string
|
||||
.replace("[list]", "", true)
|
||||
.replace("[/list]", "", true)
|
||||
.replace("[*]", "")
|
||||
.replace("[hr]", "", true)
|
||||
.replace("[u]", "", true)
|
||||
.replace("[/u]", "", true)
|
||||
.replace("[b]", "", true)
|
||||
.replace("[/b]", "", true)
|
||||
|
||||
// Recursively remove nested bbcode
|
||||
while (bbRegex.containsMatchIn(intermediate)) {
|
||||
intermediate = intermediate.replace(bbRegex, "$2")
|
||||
}
|
||||
return Parser.unescapeEntities(intermediate, false)
|
||||
}
|
||||
|
||||
fun cleanDescription(string: String): String {
|
||||
var newDescription = string
|
||||
descriptionLanguages.forEach {
|
||||
newDescription = newDescription.substringBefore(it)
|
||||
}
|
||||
|
||||
englishDescriptionTags.forEach {
|
||||
newDescription = newDescription.replace(it, "")
|
||||
}
|
||||
return cleanString(newDescription)
|
||||
}
|
||||
|
||||
fun getImageUrl(attr: String): String {
|
||||
// Some images are hosted elsewhere
|
||||
if (attr.startsWith("http")) {
|
||||
return attr
|
||||
}
|
||||
return baseUrl + attr
|
||||
}
|
||||
|
||||
fun getScanlators(scanlators: String): List<String> {
|
||||
if (scanlators.isBlank()) return emptyList()
|
||||
return scanlators.split(scanlatorSeparator).distinct()
|
||||
}
|
||||
|
||||
fun getScanlatorString(scanlators: Set<String>): String {
|
||||
return scanlators.toList().sorted().joinToString(scanlatorSeparator)
|
||||
}
|
||||
|
||||
fun getMissingChapterCount(chapters: List<SChapter>, mangaStatus: Int): String? {
|
||||
if (mangaStatus == SManga.COMPLETED) return null
|
||||
|
||||
// TODO
|
||||
val remove0ChaptersFromCount = chapters.distinctBy {
|
||||
/*if (it.chapter_txt.isNotEmpty()) {
|
||||
it.vol + it.chapter_txt
|
||||
} else {*/
|
||||
it.name
|
||||
/*}*/
|
||||
}.sortedByDescending { it.chapter_number }
|
||||
|
||||
remove0ChaptersFromCount.firstOrNull()?.let {
|
||||
val chpNumber = floor(it.chapter_number).toInt()
|
||||
val allChapters = (1..chpNumber).toMutableSet()
|
||||
|
||||
remove0ChaptersFromCount.forEach {
|
||||
allChapters.remove(floor(it.chapter_number).toInt())
|
||||
}
|
||||
|
||||
if (allChapters.size <= 0) return null
|
||||
return allChapters.size.toString()
|
||||
}
|
||||
return null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Assigns the url of the chapter without the scheme and domain. It saves some redundancy from
|
||||
* database and the urls could still work after a domain change.
|
||||
*
|
||||
* @param url the full url to the chapter.
|
||||
*/
|
||||
fun SChapter.setMDUrlWithoutDomain(url: String) {
|
||||
this.url = getMDUrlWithoutDomain(url)
|
||||
}
|
||||
|
||||
/**
|
||||
* Assigns the url of the manga without the scheme and domain. It saves some redundancy from
|
||||
* database and the urls could still work after a domain change.
|
||||
*
|
||||
* @param url the full url to the manga.
|
||||
*/
|
||||
fun SManga.setMDUrlWithoutDomain(url: String) {
|
||||
this.url = getMDUrlWithoutDomain(url)
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the url of the given string without the scheme and domain.
|
||||
*
|
||||
* @param orig the full url.
|
||||
*/
|
||||
private fun getMDUrlWithoutDomain(orig: String): String {
|
||||
return try {
|
||||
val uri = URI(orig)
|
||||
var out = uri.path
|
||||
if (uri.query != null) {
|
||||
out += "?" + uri.query
|
||||
}
|
||||
if (uri.fragment != null) {
|
||||
out += "#" + uri.fragment
|
||||
}
|
||||
out
|
||||
} catch (e: URISyntaxException) {
|
||||
orig
|
||||
}
|
||||
}
|
@ -0,0 +1,111 @@
|
||||
package exh.metadata.metadata
|
||||
|
||||
import android.content.Context
|
||||
import androidx.core.net.toUri
|
||||
import com.elvishew.xlog.XLog
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import exh.md.utils.setMDUrlWithoutDomain
|
||||
import exh.metadata.metadata.base.RaisedSearchMetadata
|
||||
import java.net.URI
|
||||
import java.net.URISyntaxException
|
||||
|
||||
class MangaDexSearchMetadata : RaisedSearchMetadata() {
|
||||
var mdId: String? = null
|
||||
|
||||
var mdUrl: String? = null
|
||||
|
||||
var thumbnail_url: String? = null
|
||||
|
||||
var title: String? by titleDelegate(TITLE_TYPE_MAIN)
|
||||
|
||||
var description: String? = null
|
||||
|
||||
var author: String? = null
|
||||
var artist: String? = null
|
||||
|
||||
var lang_flag: String? = null
|
||||
|
||||
var last_chapter_number: Int? = null
|
||||
var rating: String? = null
|
||||
var users: String? = null
|
||||
|
||||
var anilist_id: String? = null
|
||||
var kitsu_id: String? = null
|
||||
var my_anime_list_id: String? = null
|
||||
var manga_updates_id: String? = null
|
||||
var anime_planet_id: String? = null
|
||||
|
||||
var status: Int? = null
|
||||
|
||||
var missing_chapters: String? = null
|
||||
|
||||
var follow_status: Int? = null
|
||||
|
||||
override fun copyTo(manga: SManga) {
|
||||
mdUrl?.let {
|
||||
manga.url = try {
|
||||
val uri = it.toUri()
|
||||
val out = uri.path!!.removePrefix("/api")
|
||||
out + if (out.endsWith("/")) "" else "/"
|
||||
} catch (e: Exception) {
|
||||
it
|
||||
}
|
||||
}
|
||||
|
||||
title?.let {
|
||||
manga.title = it
|
||||
}
|
||||
|
||||
// Guess thumbnail URL if manga does not have thumbnail URL
|
||||
|
||||
manga.thumbnail_url = thumbnail_url
|
||||
|
||||
author?.let {
|
||||
manga.author = it
|
||||
}
|
||||
|
||||
artist?.let {
|
||||
manga.artist = it
|
||||
}
|
||||
|
||||
status?.let {
|
||||
manga.status = it
|
||||
}
|
||||
|
||||
manga.genre = tagsToGenreString()
|
||||
|
||||
description?.let {
|
||||
manga.description = it
|
||||
}
|
||||
}
|
||||
|
||||
override fun getExtraInfoPairs(context: Context): List<Pair<String, String>> {
|
||||
val pairs = mutableListOf<Pair<String, String>>()
|
||||
mdId?.let { pairs += Pair(context.getString(R.string.id), it) }
|
||||
mdUrl?.let { pairs += Pair(context.getString(R.string.url), it) }
|
||||
thumbnail_url?.let { pairs += Pair(context.getString(R.string.thumbnail_url), it) }
|
||||
title?.let { pairs += Pair(context.getString(R.string.title), it) }
|
||||
author?.let { pairs += Pair(context.getString(R.string.author), it) }
|
||||
artist?.let { pairs += Pair(context.getString(R.string.artist), it) }
|
||||
lang_flag?.let { pairs += Pair(context.getString(R.string.language), it) }
|
||||
last_chapter_number?.let { pairs += Pair(context.getString(R.string.last_chapter_number), it.toString()) }
|
||||
rating?.let { pairs += Pair(context.getString(R.string.average_rating), it) }
|
||||
users?.let { pairs += Pair(context.getString(R.string.total_ratings), it) }
|
||||
status?.let { pairs += Pair(context.getString(R.string.status), it.toString()) }
|
||||
missing_chapters?.let { pairs += Pair(context.getString(R.string.missing_chapters), it) }
|
||||
follow_status?.let { pairs += Pair(context.getString(R.string.follow_status), it.toString()) }
|
||||
anilist_id?.let { pairs += Pair(context.getString(R.string.anilist_id), it) }
|
||||
kitsu_id?.let { pairs += Pair(context.getString(R.string.kitsu_id), it) }
|
||||
my_anime_list_id?.let { pairs += Pair(context.getString(R.string.mal_id), it) }
|
||||
manga_updates_id?.let { pairs += Pair(context.getString(R.string.manga_updates_id), it) }
|
||||
anime_planet_id?.let { pairs += Pair(context.getString(R.string.anime_planet_id), it) }
|
||||
return pairs
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TITLE_TYPE_MAIN = 0
|
||||
|
||||
const val TAG_TYPE_DEFAULT = 0
|
||||
}
|
||||
}
|
@ -0,0 +1,99 @@
|
||||
package exh.ui.metadata.adapters
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.view.LayoutInflater
|
||||
import android.view.View
|
||||
import android.view.ViewGroup
|
||||
import androidx.recyclerview.widget.RecyclerView
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.databinding.DescriptionAdapterMdBinding
|
||||
import eu.kanade.tachiyomi.ui.base.controller.withFadeTransaction
|
||||
import eu.kanade.tachiyomi.ui.manga.MangaController
|
||||
import eu.kanade.tachiyomi.util.system.copyToClipboard
|
||||
import exh.metadata.metadata.MangaDexSearchMetadata
|
||||
import exh.ui.metadata.MetadataViewController
|
||||
import kotlin.math.roundToInt
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import reactivecircus.flowbinding.android.view.clicks
|
||||
import reactivecircus.flowbinding.android.view.longClicks
|
||||
|
||||
class MangaDexDescriptionAdapter(
|
||||
private val controller: MangaController
|
||||
) :
|
||||
RecyclerView.Adapter<MangaDexDescriptionAdapter.MangaDexDescriptionViewHolder>() {
|
||||
|
||||
private val scope = CoroutineScope(Job() + Dispatchers.Main)
|
||||
private lateinit var binding: DescriptionAdapterMdBinding
|
||||
|
||||
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): MangaDexDescriptionViewHolder {
|
||||
binding = DescriptionAdapterMdBinding.inflate(LayoutInflater.from(parent.context), parent, false)
|
||||
return MangaDexDescriptionViewHolder(binding.root)
|
||||
}
|
||||
|
||||
override fun getItemCount(): Int = 1
|
||||
|
||||
override fun onBindViewHolder(holder: MangaDexDescriptionViewHolder, position: Int) {
|
||||
holder.bind()
|
||||
}
|
||||
|
||||
inner class MangaDexDescriptionViewHolder(view: View) : RecyclerView.ViewHolder(view) {
|
||||
|
||||
fun bind() {
|
||||
val meta = controller.presenter.meta
|
||||
if (meta == null || meta !is MangaDexSearchMetadata) return
|
||||
|
||||
@SuppressLint("SetTextI18n")
|
||||
binding.mdId.text = "#" + (meta.mdId ?: 0)
|
||||
|
||||
val ratingFloat = meta.rating?.toFloatOrNull()?.div(2F)
|
||||
val name = when (((ratingFloat ?: 100F) * 2).roundToInt()) {
|
||||
0 -> R.string.rating0
|
||||
1 -> R.string.rating1
|
||||
2 -> R.string.rating2
|
||||
3 -> R.string.rating3
|
||||
4 -> R.string.rating4
|
||||
5 -> R.string.rating5
|
||||
6 -> R.string.rating6
|
||||
7 -> R.string.rating7
|
||||
8 -> R.string.rating8
|
||||
9 -> R.string.rating9
|
||||
10 -> R.string.rating10
|
||||
else -> R.string.no_rating
|
||||
}
|
||||
binding.ratingBar.rating = ratingFloat ?: 0F
|
||||
binding.rating.text = if (meta.users?.toIntOrNull() != null) {
|
||||
itemView.context.getString(R.string.rating_view, itemView.context.getString(name), (meta.rating?.toFloatOrNull() ?: 0F).toString(), meta.users?.toIntOrNull() ?: 0)
|
||||
} else {
|
||||
itemView.context.getString(R.string.rating_view_no_count, itemView.context.getString(name), (meta.rating?.toFloatOrNull() ?: 0F).toString())
|
||||
}
|
||||
|
||||
listOf(
|
||||
binding.mdId,
|
||||
binding.rating
|
||||
).forEach { textView ->
|
||||
textView.longClicks()
|
||||
.onEach {
|
||||
itemView.context.copyToClipboard(
|
||||
textView.text.toString(),
|
||||
textView.text.toString()
|
||||
)
|
||||
}
|
||||
.launchIn(scope)
|
||||
}
|
||||
|
||||
binding.moreInfo.clicks()
|
||||
.onEach {
|
||||
controller.router?.pushController(
|
||||
MetadataViewController(
|
||||
controller.manga
|
||||
).withFadeTransaction()
|
||||
)
|
||||
}
|
||||
.launchIn(scope)
|
||||
}
|
||||
}
|
||||
}
|
59
app/src/main/res/layout/description_adapter_md.xml
Normal file
59
app/src/main/res/layout/description_adapter_md.xml
Normal file
@ -0,0 +1,59 @@
|
||||
<?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/mdId"
|
||||
style="@style/TextAppearance.Regular"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="16dp"
|
||||
app:layout_constraintBottom_toBottomOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent"/>
|
||||
|
||||
|
||||
<me.zhanghai.android.materialratingbar.MaterialRatingBar
|
||||
android:id="@+id/rating_bar"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="25dp"
|
||||
android:layout_gravity="center_horizontal"
|
||||
android:focusable="false"
|
||||
android:isIndicator="true"
|
||||
android:numStars="5"
|
||||
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="@style/Theme.Widget.Button"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginEnd="16dp"
|
||||
android:text="@string/more_info"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintTop_toTopOf="parent" />
|
||||
|
||||
</androidx.constraintlayout.widget.ConstraintLayout>
|
||||
|
||||
<com.google.android.material.textview.MaterialTextView
|
||||
android:id="@+id/rating"
|
||||
style="@style/TextAppearance.Regular"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_gravity="center_horizontal"
|
||||
app:layout_constraintEnd_toEndOf="parent"
|
||||
app:layout_constraintStart_toStartOf="parent"
|
||||
app:layout_constraintTop_toBottomOf="@+id/rating_bar" />
|
||||
|
||||
|
||||
</LinearLayout>
|
@ -215,6 +215,11 @@
|
||||
<string name="az_recommends">See Recommendations</string>
|
||||
<string name="merge_with_another_source">Merge With Another</string>
|
||||
|
||||
<!-- Manga info fragment -->
|
||||
<string name="hiatus">Hiatus</string>
|
||||
<string name="cancelled">Cancelled</string>
|
||||
<string name="publication_complete">Publication Complete</string>
|
||||
|
||||
<!-- Manga Info Edit -->
|
||||
<string name="reset_tags">Reset Tags</string>
|
||||
<string name="reset_cover">Reset Cover</string>
|
||||
@ -433,6 +438,15 @@
|
||||
<string name="rating_string">Rating string</string>
|
||||
<string name="collection">Collection</string>
|
||||
<string name="parodies">Parodies</string>
|
||||
<string name="author">Author</string>
|
||||
<string name="last_chapter_number">Last chapter number</string>
|
||||
<string name="missing_chapters">Missing chapters</string>
|
||||
<string name="follow_status">Follow status</string>
|
||||
<string name="anilist_id">Anilist id</string>
|
||||
<string name="kitsu_id">Kitsu id</string>
|
||||
<string name="mal_id">Mal id</string>
|
||||
<string name="manga_updates_id">Manga updates id</string>
|
||||
<string name="anime_planet_id">Anime planet id</string>
|
||||
|
||||
<!-- Extra gallery info -->
|
||||
<plurals name="num_pages">
|
||||
|
@ -46,6 +46,9 @@ buildscript {
|
||||
// Realm (EH)
|
||||
classpath("io.realm:realm-gradle-plugin:7.0.1")
|
||||
|
||||
// SY for mangadex utils
|
||||
classpath("org.jetbrains.kotlin:kotlin-serialization:${BuildPluginsVersion.KOTLIN}")
|
||||
|
||||
// Firebase (EH)
|
||||
classpath("io.fabric.tools:gradle:1.31.0")
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user