Finish some more advanced mangadex delegation features, more to come later

This commit is contained in:
Jobobby04 2020-08-20 20:50:37 -04:00
parent 294ade035e
commit 0deb6f6b8d
31 changed files with 2274 additions and 4 deletions

View File

@ -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"

View File

@ -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

View File

@ -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"
}

View File

@ -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)
}

View File

@ -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

View File

@ -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()

View File

@ -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)
}
}

View File

@ -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 <--

View File

@ -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
}
)

View 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("/")
}
}

View 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
}
}

View 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" }
}
}
}

View 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()
}
}

View 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
}
}
}

View 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)
}
}

View 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()
}
}

View 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)
}
}

View 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)"
}
}

View 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"
}
}

View 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))
}
}*/

View File

@ -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?
)

View File

@ -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
)

View File

@ -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
)

View 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 }
}
}

View 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)
}

View 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&ntilde;ol / Spanish",
"Spanish / Espa&ntilde;ol",
"Spanish / Espa & ntilde; ol",
"Spanish / Espa&ntilde;ol",
"[b][u]Spanish",
"[Espa&ntilde;ol]:",
"[b] Spanish: [/ b]",
"Spanish/Espa&ntilde;ol",
"Espa&ntilde;ol / Spanish",
"Italian / Italiano",
"Pasta-Pizza-Mandolino/Italiano",
"Polish / polski",
"Polish / Polski",
"Polish Summary / Polski Opis",
"Polski",
"Portuguese (BR) / Portugu&ecirc;s",
"Portuguese / Portugu&ecirc;s",
"Português / Portuguese",
"Portuguese / Portugu",
"Portuguese / Portugu&ecirc;s",
"Portugu&ecirc;s",
"Portuguese (BR) / Portugu & ecirc;",
"Portuguese (BR) / Portugu&ecirc;",
"[PTBR]",
"R&eacute;sume Fran&ccedil;ais",
"R&eacute;sum&eacute; Fran&ccedil;ais",
"[b][u]French",
"French / Fran&ccedil;ais",
"Fran&ccedil;ais",
"[hr]Fr:",
"French - Français:",
"Turkish / T&uuml;rk&ccedil;e",
"Turkish/T&uuml;rk&ccedil;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
}
}

View File

@ -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
}
}

View File

@ -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)
}
}
}

View 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>

View File

@ -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">

View File

@ -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")
}