diff --git a/app/build.gradle b/app/build.gradle index 912e55bfe..792eaf90d 100755 --- a/app/build.gradle +++ b/app/build.gradle @@ -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" diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 90db77d89..ea0e75423 100755 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -282,7 +282,7 @@ android:scheme="https" /> - + android:scheme="https" /> Mangadex from Neko todo + const val MDLIST = 60 + // SY <-- + // SY --> const val READING = 1 const val REREADING = 2 diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/model/SManga.kt b/app/src/main/java/eu/kanade/tachiyomi/source/model/SManga.kt index a2dff45af..76d3c5a0d 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/source/model/SManga.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/model/SManga.kt @@ -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() diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/all/MangaDex.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/all/MangaDex.kt index bde12758c..b55992235 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/source/online/all/MangaDex.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/all/MangaDex.kt @@ -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, UrlImportableSource { override val lang: String = delegate.lang + private val mdLang by lazy { + MdLang.values().find { it.lang == lang }?.dexLang ?: lang + } + override val matchingHosts: List = listOf("mangadex.org", "www.mangadex.org") override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable = @@ -31,4 +56,46 @@ class MangaDex(delegate: HttpSource, val context: Context) : null } } + + override fun fetchMangaDetails(manga: SManga): Observable { + return MangaHandler(client, headers, listOf(mdLang)).fetchMangaDetailsObservable(manga) + } + + override fun fetchChapterList(manga: SManga): Observable> { + return MangaHandler(client, headers, listOf(mdLang)).fetchChapterListObservable(manga) + } + + @ExperimentalSerializationApi + override fun fetchPageList(chapter: SChapter): Observable> { + 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 { + 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::class + + override fun getDescriptionAdapter(controller: MangaController): MangaDexDescriptionAdapter { + return MangaDexDescriptionAdapter(controller) + } + + override fun parseIntoMetadata(metadata: MangaDexSearchMetadata, input: Response) { + ApiMangaParser(listOf(mdLang)).parseIntoMetadata(metadata, input) + } } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaPresenter.kt index 08f494ee6..8f66fce47 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaPresenter.kt @@ -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 <-- diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaInfoHeaderAdapter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaInfoHeaderAdapter.kt index 6eb2527eb..acc5c79f5 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaInfoHeaderAdapter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/info/MangaInfoHeaderAdapter.kt @@ -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 } ) diff --git a/app/src/main/java/exh/md/handlers/ApiChapterParser.kt b/app/src/main/java/exh/md/handlers/ApiChapterParser.kt new file mode 100644 index 000000000..0e0c3d250 --- /dev/null +++ b/app/src/main/java/exh/md/handlers/ApiChapterParser.kt @@ -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 { + val jsonData = response.body!!.string() + val json = JsonParser.parseString(jsonData).asJsonObject + + val pages = mutableListOf() + + 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("/") + } +} diff --git a/app/src/main/java/exh/md/handlers/ApiMangaParser.kt b/app/src/main/java/exh/md/handlers/ApiMangaParser.kt new file mode 100644 index 000000000..25590bb9f --- /dev/null +++ b/app/src/main/java/exh/md/handlers/ApiMangaParser.kt @@ -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) { + 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> + ): 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> { + 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 { + return chapterListParse(response.body!!.string()) + } + + fun chapterListParse(jsonData: String): List { + 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() + + // 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, + totalChapterCount: Int + ): SChapter { + val chapter = SChapter.create() + chapter.url = MdUtil.apiChapter + chapterId + val chapterName = mutableListOf() + // 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() + + 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 + } +} diff --git a/app/src/main/java/exh/md/handlers/CoverHandler.kt b/app/src/main/java/exh/md/handlers/CoverHandler.kt new file mode 100644 index 000000000..6443e120d --- /dev/null +++ b/app/src/main/java/exh/md/handlers/CoverHandler.kt @@ -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 { + 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" } + } + } +} diff --git a/app/src/main/java/exh/md/handlers/FilterHandler.kt b/app/src/main/java/exh/md/handlers/FilterHandler.kt new file mode 100644 index 000000000..3e5467eb0 --- /dev/null +++ b/app/src/main/java/exh/md/handlers/FilterHandler.kt @@ -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) : Filter.Group("Content", contents) + class FormatList(formats: List) : Filter.Group("Format", formats) + class GenreList(genres: List) : Filter.Group("Genres", genres) + class PublicationStatusList(statuses: List) : Filter.Group("Publication Status", statuses) + class DemographicList(demographics: List) : Filter.Group("Demographic", demographics) + + class R18 : Filter.Select("R18+", arrayOf("Default", "Show all", "Show only", "Show none")) + class ThemeList(themes: List) : Filter.Group("Themes", themes) + class TagInclusionMode : Filter.Select("Tag inclusion", arrayOf("All (and)", "Any (or)"), 0) + class TagExclusionMode : Filter.Select("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("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() + } +} diff --git a/app/src/main/java/exh/md/handlers/FollowsHandler.kt b/app/src/main/java/exh/md/handlers/FollowsHandler.kt new file mode 100644 index 000000000..e3266afa8 --- /dev/null +++ b/app/src/main/java/exh/md/handlers/FollowsHandler.kt @@ -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 { + 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 { + 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 { + return withContext(Dispatchers.IO) { + val listManga = mutableListOf() + 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 + } + } +} diff --git a/app/src/main/java/exh/md/handlers/MangaHandler.kt b/app/src/main/java/exh/md/handlers/MangaHandler.kt new file mode 100644 index 000000000..fc39f81cf --- /dev/null +++ b/app/src/main/java/exh/md/handlers/MangaHandler.kt @@ -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) { + + // TODO make use of this + suspend fun fetchMangaAndChapterDetails(manga: SManga): Pair> { + 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 { + return client.newCall(apiRequest(manga)) + .asObservableSuccess() + .flatMap { + ApiMangaParser(langs).parseToManga(manga, it).andThen( + Observable.just( + manga.apply { + initialized = true + } + ) + ) + } + } + + fun fetchChapterListObservable(manga: SManga): Observable> { + return client.newCall(apiRequest(manga)) + .asObservableSuccess() + .map { response -> + ApiMangaParser(langs).chapterListParse(response) + } + } + + suspend fun fetchChapterList(manga: SManga): List { + return withContext(Dispatchers.IO) { + val response = client.newCall(apiRequest(manga)).execute() + ApiMangaParser(langs).chapterListParse(response) + } + } + + fun fetchRandomMangaId(): Observable { + 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) + } +} diff --git a/app/src/main/java/exh/md/handlers/MangaPlusHandler.kt b/app/src/main/java/exh/md/handlers/MangaPlusHandler.kt new file mode 100644 index 000000000..42cc81889 --- /dev/null +++ b/app/src/main/java/exh/md/handlers/MangaPlusHandler.kt @@ -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 { + 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 { + 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() + } +} diff --git a/app/src/main/java/exh/md/handlers/PageHandler.kt b/app/src/main/java/exh/md/handlers/PageHandler.kt new file mode 100644 index 000000000..5d56c9c6f --- /dev/null +++ b/app/src/main/java/exh/md/handlers/PageHandler.kt @@ -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> { + 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) + } +} diff --git a/app/src/main/java/exh/md/handlers/PopularHandler.kt b/app/src/main/java/exh/md/handlers/PopularHandler.kt new file mode 100644 index 000000000..96998e6ce --- /dev/null +++ b/app/src/main/java/exh/md/handlers/PopularHandler.kt @@ -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 { + 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)" + } +} diff --git a/app/src/main/java/exh/md/handlers/SearchHandler.kt b/app/src/main/java/exh/md/handlers/SearchHandler.kt new file mode 100644 index 000000000..8a0af588f --- /dev/null +++ b/app/src/main/java/exh/md/handlers/SearchHandler.kt @@ -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) { + + private val preferences: PreferencesHelper by injectLazy() + + fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable { + 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() + val statuses = mutableListOf() + val demographics = mutableListOf() + + // 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" + } +} diff --git a/app/src/main/java/exh/md/handlers/SimilarHandler.kt b/app/src/main/java/exh/md/handlers/SimilarHandler.kt new file mode 100644 index 000000000..c245e974a --- /dev/null +++ b/app/src/main/java/exh/md/handlers/SimilarHandler.kt @@ -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 { + + // 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() + 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)) + } +}*/ diff --git a/app/src/main/java/exh/md/handlers/serializers/ApiMangaSerializer.kt b/app/src/main/java/exh/md/handlers/serializers/ApiMangaSerializer.kt new file mode 100644 index 000000000..c2a3f90d4 --- /dev/null +++ b/app/src/main/java/exh/md/handlers/serializers/ApiMangaSerializer.kt @@ -0,0 +1,74 @@ +package exh.md.handlers.serializers + +import kotlinx.serialization.Serializable + +@Serializable +data class ApiMangaSerializer( + val chapter: Map? = 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, + 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 = emptyList(), + val status: String +) + +@Serializable +data class ImageReportResult( + val url: String, + val success: Boolean, + val bytes: Int? +) diff --git a/app/src/main/java/exh/md/handlers/serializers/FollowsPageSerializer.kt b/app/src/main/java/exh/md/handlers/serializers/FollowsPageSerializer.kt new file mode 100644 index 000000000..1114f586c --- /dev/null +++ b/app/src/main/java/exh/md/handlers/serializers/FollowsPageSerializer.kt @@ -0,0 +1,17 @@ +package exh.md.handlers.serializers + +import kotlinx.serialization.Serializable + +@Serializable +data class FollowsPageResult( + val result: List = emptyList() +) + +@Serializable +data class Result( + val title: String, + val chapter: String, + val follow_type: Int, + val manga_id: Int, + val volume: String +) diff --git a/app/src/main/java/exh/md/handlers/serializers/MangaPlusSerializer.kt b/app/src/main/java/exh/md/handlers/serializers/MangaPlusSerializer.kt new file mode 100644 index 000000000..72b610490 --- /dev/null +++ b/app/src/main/java/exh/md/handlers/serializers/MangaPlusSerializer.kt @@ -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 = 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 +) diff --git a/app/src/main/java/exh/md/utils/FollowStatus.kt b/app/src/main/java/exh/md/utils/FollowStatus.kt new file mode 100644 index 000000000..e2ae63f77 --- /dev/null +++ b/app/src/main/java/exh/md/utils/FollowStatus.kt @@ -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 } + } +} diff --git a/app/src/main/java/exh/md/utils/MdLang.kt b/app/src/main/java/exh/md/utils/MdLang.kt new file mode 100644 index 000000000..3e6c768f1 --- /dev/null +++ b/app/src/main/java/exh/md/utils/MdLang.kt @@ -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) +} diff --git a/app/src/main/java/exh/md/utils/MdUtil.kt b/app/src/main/java/exh/md/utils/MdUtil.kt new file mode 100644 index 000000000..91f5a1e7b --- /dev/null +++ b/app/src/main/java/exh/md/utils/MdUtil.kt @@ -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 + } +} diff --git a/app/src/main/java/exh/metadata/metadata/MangaDexSearchMetadata.kt b/app/src/main/java/exh/metadata/metadata/MangaDexSearchMetadata.kt new file mode 100644 index 000000000..3c9e95232 --- /dev/null +++ b/app/src/main/java/exh/metadata/metadata/MangaDexSearchMetadata.kt @@ -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 + } +} diff --git a/app/src/main/java/exh/ui/metadata/adapters/MangaDexDescriptionAdapter.kt b/app/src/main/java/exh/ui/metadata/adapters/MangaDexDescriptionAdapter.kt new file mode 100644 index 000000000..cf685ef57 --- /dev/null +++ b/app/src/main/java/exh/ui/metadata/adapters/MangaDexDescriptionAdapter.kt @@ -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) + } + } +} diff --git a/app/src/main/res/layout/description_adapter_md.xml b/app/src/main/res/layout/description_adapter_md.xml new file mode 100644 index 000000000..d15b0ed21 --- /dev/null +++ b/app/src/main/res/layout/description_adapter_md.xml @@ -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> \ No newline at end of file diff --git a/app/src/main/res/values/strings_sy.xml b/app/src/main/res/values/strings_sy.xml index 332afa75a..4923f2525 100644 --- a/app/src/main/res/values/strings_sy.xml +++ b/app/src/main/res/values/strings_sy.xml @@ -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"> diff --git a/build.gradle.kts b/build.gradle.kts index 1ecd13c9b..7097f8e38 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -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") }