diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/SourceManager.kt b/app/src/main/java/eu/kanade/tachiyomi/source/SourceManager.kt index 07621cc3d..da5f6ad85 100755 --- a/app/src/main/java/eu/kanade/tachiyomi/source/SourceManager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/source/SourceManager.kt @@ -12,6 +12,7 @@ import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.source.online.all.EHentai import eu.kanade.tachiyomi.source.online.all.Hitomi import eu.kanade.tachiyomi.source.online.all.MangaDex +import eu.kanade.tachiyomi.source.online.all.MangaPlus import eu.kanade.tachiyomi.source.online.all.MergedSource import eu.kanade.tachiyomi.source.online.all.NHentai import eu.kanade.tachiyomi.source.online.all.PervEden @@ -237,6 +238,13 @@ open class SourceManager(private val context: Context) { "eu.kanade.tachiyomi.extension.all.nhentai.NHentai", NHentai::class, true + ), + DelegatedSource( + "MANGA Plus", + fillInSourceId, + "eu.kanade.tachiyomi.extension.all.mangaplus.MangaPlus", + MangaPlus::class, + true ) ).associateBy { it.originalSourceQualifiedClassName } diff --git a/app/src/main/java/eu/kanade/tachiyomi/source/online/all/MangaPlus.kt b/app/src/main/java/eu/kanade/tachiyomi/source/online/all/MangaPlus.kt new file mode 100644 index 000000000..a4edc15c6 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/source/online/all/MangaPlus.kt @@ -0,0 +1,372 @@ +package eu.kanade.tachiyomi.source.online.all + +import android.app.Application +import android.content.Context +import android.content.SharedPreferences +import androidx.preference.CheckBoxPreference +import androidx.preference.ListPreference +import androidx.preference.PreferenceScreen +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.asObservableSuccess +import eu.kanade.tachiyomi.source.ConfigurableSource +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 exh.md.handlers.serializers.ErrorResult +import exh.md.handlers.serializers.Language +import exh.md.handlers.serializers.MangaPlusResponse +import exh.md.handlers.serializers.MangaPlusSerializer +import exh.md.handlers.serializers.Popup +import exh.md.handlers.serializers.Title +import exh.source.DelegatedHttpSource +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.protobuf.ProtoBuf +import okhttp3.HttpUrl.Companion.toHttpUrlOrNull +import okhttp3.Request +import okhttp3.Response +import rx.Observable +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get + +@ExperimentalSerializationApi +class MangaPlus(delegate: HttpSource, val context: Context) : + DelegatedHttpSource(delegate), + ConfigurableSource { + override val lang = delegate.lang + + private val internalLang: String + get() = when (lang) { + "es" -> "esp" + else -> "eng" + } + + private val langCode: Language + get() = when (lang) { + "es" -> Language.SPANISH + else -> Language.ENGLISH + } + + private val preferences: SharedPreferences by lazy { + Injekt.get().getSharedPreferences("source_$id", 0x0000) + } + + private val imageResolution: String + get() = preferences.getString("${RESOLUTION_PREF_KEY}_$lang", RESOLUTION_PREF_DEFAULT_VALUE)!! + + private val splitImages: String + get() = if (preferences.getBoolean("${SPLIT_PREF_KEY}_$lang", SPLIT_PREF_DEFAULT_VALUE)) "yes" else "no" + + private var titleList: List? = null + + override fun fetchPopularManga(page: Int): Observable<MangasPage> { + return client.newCall(mpPopularMangaRequest()) + .asObservableSuccess() + .map { response -> + mpPopularMangaParse(response) + } + } + + private fun mpPopularMangaRequest(): Request { + val newHeaders = headersBuilder() + .set("Referer", "$baseUrl/manga_list/hot") + .build() + + return GET("$API_URL/title_list/ranking", newHeaders) + } + + private fun mpPopularMangaParse(response: Response): MangasPage { + val result = response.asProto() + + if (result.success == null) { + throw Exception(result.error!!.langPopup.body) + } + + titleList = result.success.titleRankingView!!.titles + .filter { it.language == langCode } + + val mangas = titleList!!.map { + SManga.create().apply { + title = it.name + thumbnail_url = it.portraitImageUrl + url = "#/titles/${it.titleId}" + } + } + + return MangasPage(mangas, false) + } + + override fun fetchLatestUpdates(page: Int): Observable<MangasPage> { + return client.newCall(mpLatestUpdatesRequest()) + .asObservableSuccess() + .map { response -> + mpLatestUpdatesParse(response) + } + } + + private fun mpLatestUpdatesRequest(): Request { + val newHeaders = headersBuilder() + .set("Referer", "$baseUrl/updates") + .build() + + return GET("$API_URL/web/web_home?lang=$internalLang", newHeaders) + } + + private fun mpLatestUpdatesParse(response: Response): MangasPage { + val result = response.asProto() + + if (result.success == null) { + throw Exception(result.error!!.langPopup.body) + } + + // Fetch all titles to get newer thumbnail urls at the interceptor. + val popularResponse = client.newCall(mpPopularMangaRequest()).execute().asProto() + + if (popularResponse.success != null) { + titleList = popularResponse.success.titleRankingView!!.titles + .filter { it.language == langCode } + } + + val mangas = result.success.webHomeView!!.groups + .flatMap { it.titles } + .mapNotNull { it.title } + .filter { it.language == langCode } + .map { + SManga.create().apply { + title = it.name + thumbnail_url = it.portraitImageUrl + url = "#/titles/${it.titleId}" + } + } + .distinctBy { it.title } + + return MangasPage(mangas, false) + } + + override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> { + return client.newCall(mpSearchMangaRequest()) + .asObservableSuccess() + .map { response -> + mpSearchMangaParse(response) + } + .map { MangasPage(it.mangas.filter { m -> m.title.contains(query, true) }, it.hasNextPage) } + } + + private fun mpSearchMangaRequest(): Request { + val newHeaders = headersBuilder() + .set("Referer", "$baseUrl/manga_list/all") + .build() + + return GET("$API_URL/title_list/all", newHeaders) + } + + private fun mpSearchMangaParse(response: Response): MangasPage { + val result = response.asProto() + + if (result.success == null) { + throw Exception(result.error!!.langPopup.body) + } + + titleList = result.success.allTitlesView!!.titles + .filter { it.language == langCode } + + val mangas = titleList!!.map { + SManga.create().apply { + title = it.name + thumbnail_url = it.portraitImageUrl + url = "#/titles/${it.titleId}" + } + } + + return MangasPage(mangas, false) + } + + private fun titleDetailsRequest(manga: SManga): Request { + val titleId = manga.url.substringAfterLast("/") + + val newHeaders = headersBuilder() + .set("Referer", "$baseUrl/titles/$titleId") + .build() + + return GET("$API_URL/title_detail?title_id=$titleId", newHeaders) + } + + // Workaround to allow "Open in browser" use the real URL. + override fun fetchMangaDetails(manga: SManga): Observable<SManga> { + return client.newCall(titleDetailsRequest(manga)) + .asObservableSuccess() + .map { response -> + mpMangaDetailsParse(response).apply { initialized = true } + } + } + + private fun mpMangaDetailsParse(response: Response): SManga { + val result = response.asProto() + + if (result.success == null) { + throw Exception(result.error!!.langPopup.body) + } + + val details = result.success.titleDetailView!! + val title = details.title + val isCompleted = details.nonAppearanceInfo.contains(COMPLETE_REGEX) + + return SManga.create().apply { + author = title.author.replace(" / ", ", ") + artist = author + description = details.overview + "\n\n" + details.viewingPeriodDescription + status = if (isCompleted) SManga.COMPLETED else SManga.ONGOING + thumbnail_url = title.portraitImageUrl + } + } + + override fun fetchChapterList(manga: SManga): Observable<List<SChapter>> { + return if (manga.status != SManga.LICENSED) { + client.newCall(chapterListRequest(manga)) + .asObservableSuccess() + .map { response -> + mpChapterListParse(response) + } + } else { + Observable.error(Exception("Licensed - No chapters to show")) + } + } + + override fun chapterListRequest(manga: SManga): Request = titleDetailsRequest(manga) + + private fun mpChapterListParse(response: Response): List<SChapter> { + val result = response.asProto() + + if (result.success == null) { + throw Exception(result.error!!.langPopup.body) + } + + val titleDetailView = result.success.titleDetailView!! + + val chapters = titleDetailView.firstChapterList + titleDetailView.lastChapterList + + return chapters.reversed() + // If the subTitle is null, then the chapter time expired. + .filter { it.subTitle != null } + .map { + SChapter.create().apply { + name = "${it.name} - ${it.subTitle}" + scanlator = "Shueisha" + date_upload = 1000L * it.startTimeStamp + url = "#/viewer/${it.chapterId}" + chapter_number = it.name.substringAfter("#").toFloatOrNull() ?: 0f + } + } + } + + override fun fetchPageList(chapter: SChapter): Observable<List<Page>> { + return client.newCall(pageListRequest(chapter)) + .asObservableSuccess() + .map { response -> + mpPageListParse(response) + } + } + + override fun pageListRequest(chapter: SChapter): Request { + val chapterId = chapter.url.substringAfterLast("/") + + val newHeaders = headersBuilder() + .set("Referer", "$baseUrl/viewer/$chapterId") + .build() + + val url = "$API_URL/manga_viewer".toHttpUrlOrNull()!!.newBuilder() + .addQueryParameter("chapter_id", chapterId) + .addQueryParameter("split", splitImages) + .addQueryParameter("img_quality", imageResolution) + .toString() + + return GET(url, newHeaders) + } + + private fun mpPageListParse(response: Response): List<Page> { + val result = response.asProto() + + if (result.success == null) { + throw Exception(result.error!!.langPopup.body) + } + + val referer = response.request.header("Referer")!! + + return result.success.mangaViewer!!.pages + .mapNotNull { it.page } + .mapIndexed { i, page -> + val encryptionKey = if (page.encryptionKey == null) "" else "&encryptionKey=${page.encryptionKey}" + Page(i, referer, "${page.imageUrl}$encryptionKey") + } + } + + override fun imageRequest(page: Page): Request { + val newHeaders = headersBuilder() + .removeAll("Origin") + .set("Referer", page.url) + .build() + + return GET(page.imageUrl!!, newHeaders) + } + + override fun setupPreferenceScreen(screen: PreferenceScreen) { + val resolutionPref = ListPreference(screen.context).apply { + key = "${RESOLUTION_PREF_KEY}_$lang" + title = RESOLUTION_PREF_TITLE + entries = RESOLUTION_PREF_ENTRIES + entryValues = RESOLUTION_PREF_ENTRY_VALUES + setDefaultValue(RESOLUTION_PREF_DEFAULT_VALUE) + summary = "%s" + + setOnPreferenceChangeListener { _, newValue -> + val selected = newValue as String + val index = findIndexOfValue(selected) + val entry = entryValues[index] as String + preferences.edit().putString("${RESOLUTION_PREF_KEY}_$lang", entry).commit() + } + } + val splitPref = CheckBoxPreference(screen.context).apply { + key = "${SPLIT_PREF_KEY}_$lang" + title = SPLIT_PREF_TITLE + summary = SPLIT_PREF_SUMMARY + setDefaultValue(SPLIT_PREF_DEFAULT_VALUE) + + setOnPreferenceChangeListener { _, newValue -> + val checkValue = newValue as Boolean + preferences.edit().putBoolean("${SPLIT_PREF_KEY}_$lang", checkValue).commit() + } + } + + screen.addPreference(resolutionPref) + screen.addPreference(splitPref) + } + + private val ErrorResult.langPopup: Popup + get() = when (lang) { + "es" -> spanishPopup + else -> englishPopup + } + + private fun Response.asProto(): MangaPlusResponse { + return ProtoBuf.decodeFromByteArray(MangaPlusSerializer, body!!.bytes()) + } + + companion object { + private const val API_URL = "https://jumpg-webapi.tokyo-cdn.com/api" + + private const val RESOLUTION_PREF_KEY = "imageResolution" + private const val RESOLUTION_PREF_TITLE = "Image resolution" + private val RESOLUTION_PREF_ENTRIES = arrayOf("Low resolution", "Medium resolution", "High resolution") + private val RESOLUTION_PREF_ENTRY_VALUES = arrayOf("low", "high", "super_high") + private val RESOLUTION_PREF_DEFAULT_VALUE = RESOLUTION_PREF_ENTRY_VALUES[2] + + private const val SPLIT_PREF_KEY = "splitImage" + private const val SPLIT_PREF_TITLE = "Split double pages" + private const val SPLIT_PREF_SUMMARY = "Not all titles support disabling this." + private const val SPLIT_PREF_DEFAULT_VALUE = true + + private val COMPLETE_REGEX = "completado|complete".toRegex() + } +} diff --git a/app/src/main/java/exh/md/handlers/MangaPlusHandler.kt b/app/src/main/java/exh/md/handlers/MangaPlusHandler.kt index 42cc81889..f941dd20c 100644 --- a/app/src/main/java/exh/md/handlers/MangaPlusHandler.kt +++ b/app/src/main/java/exh/md/handlers/MangaPlusHandler.kt @@ -1,8 +1,8 @@ package exh.md.handlers -import MangaPlusSerializer import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.source.model.Page +import exh.md.handlers.serializers.MangaPlusSerializer import java.util.UUID import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.protobuf.ProtoBuf diff --git a/app/src/main/java/exh/md/handlers/serializers/MangaPlusSerializer.kt b/app/src/main/java/exh/md/handlers/serializers/MangaPlusSerializer.kt index 72b610490..044cc9765 100644 --- a/app/src/main/java/exh/md/handlers/serializers/MangaPlusSerializer.kt +++ b/app/src/main/java/exh/md/handlers/serializers/MangaPlusSerializer.kt @@ -1,3 +1,5 @@ +package exh.md.handlers.serializers + import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.Serializable import kotlinx.serialization.Serializer @@ -64,6 +66,7 @@ data class TitleDetailView( @ProtoNumber(5) val nextTimeStamp: Int = 0, @ProtoNumber(6) val updateTiming: UpdateTiming? = UpdateTiming.DAY, @ProtoNumber(7) val viewingPeriodDescription: String = "", + @ProtoNumber(8) val nonAppearanceInfo: String = "", @ProtoNumber(9) val firstChapterList: List<Chapter> = emptyList(), @ProtoNumber(10) val lastChapterList: List<Chapter> = emptyList(), @ProtoNumber(14) val isSimulReleased: Boolean = true,