diff --git a/src/all/mangaup/AndroidManifest.xml b/src/all/mangaup/AndroidManifest.xml new file mode 100644 index 000000000..02005096f --- /dev/null +++ b/src/all/mangaup/AndroidManifest.xml @@ -0,0 +1,28 @@ + + + + + + + + + + + + + + + + + diff --git a/src/all/mangaup/README.md b/src/all/mangaup/README.md new file mode 100644 index 000000000..ab000dc15 --- /dev/null +++ b/src/all/mangaup/README.md @@ -0,0 +1,16 @@ +# Manga UP! + +Table of Content +- [FAQ](#FAQ) + - [Why chapters are missing in some titles?](#why-chapters-are-missing-in-some-titles) + +## FAQ + +### Why chapters are missing in some titles? + +Manga UP! have series with paid chapters. These will be filtered out from the chapter +list by default, even if you have bought then before. To read these chapters, use their +official app to purchase with their coin system and read there. + +To check if a chapter is paid, open the WebView and check if the chapter thumbnail is +grayed out with a "Exclusive on app" warning or has an "Advanced" badge. diff --git a/src/all/mangaup/build.gradle b/src/all/mangaup/build.gradle new file mode 100644 index 000000000..fd180f392 --- /dev/null +++ b/src/all/mangaup/build.gradle @@ -0,0 +1,12 @@ +apply plugin: 'com.android.application' +apply plugin: 'kotlin-android' +apply plugin: 'kotlinx-serialization' + +ext { + extName = 'Manga UP!' + pkgNameSuffix = 'all.mangaup' + extClass = '.MangaUpFactory' + extVersionCode = 1 +} + +apply from: "$rootDir/common.gradle" diff --git a/src/all/mangaup/res/mipmap-hdpi/ic_launcher.png b/src/all/mangaup/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..180dc064d Binary files /dev/null and b/src/all/mangaup/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/all/mangaup/res/mipmap-mdpi/ic_launcher.png b/src/all/mangaup/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..05827ee90 Binary files /dev/null and b/src/all/mangaup/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/all/mangaup/res/mipmap-xhdpi/ic_launcher.png b/src/all/mangaup/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..9281fd97a Binary files /dev/null and b/src/all/mangaup/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/all/mangaup/res/mipmap-xxhdpi/ic_launcher.png b/src/all/mangaup/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..59d1d2b7e Binary files /dev/null and b/src/all/mangaup/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/all/mangaup/res/mipmap-xxxhdpi/ic_launcher.png b/src/all/mangaup/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..a82fdcee6 Binary files /dev/null and b/src/all/mangaup/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/all/mangaup/res/web_hi_res_512.png b/src/all/mangaup/res/web_hi_res_512.png new file mode 100644 index 000000000..2bd22fc93 Binary files /dev/null and b/src/all/mangaup/res/web_hi_res_512.png differ diff --git a/src/all/mangaup/src/eu/kanade/tachiyomi/extension/all/mangaup/MangaUp.kt b/src/all/mangaup/src/eu/kanade/tachiyomi/extension/all/mangaup/MangaUp.kt new file mode 100644 index 000000000..8b85840cf --- /dev/null +++ b/src/all/mangaup/src/eu/kanade/tachiyomi/extension/all/mangaup/MangaUp.kt @@ -0,0 +1,194 @@ +package eu.kanade.tachiyomi.extension.all.mangaup + +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.asObservableSuccess +import eu.kanade.tachiyomi.network.interceptor.rateLimitHost +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 kotlinx.serialization.decodeFromString +import kotlinx.serialization.json.Json +import okhttp3.Headers +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.Interceptor +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import okhttp3.internal.closeQuietly +import rx.Observable +import uy.kohesive.injekt.injectLazy + +class MangaUp(override val lang: String) : HttpSource() { + + override val name = "Manga UP!" + + override val baseUrl = "https://global.manga-up.com" + + override val supportsLatest = false + + override fun headersBuilder(): Headers.Builder = Headers.Builder() + .add("Origin", baseUrl) + .add("Referer", baseUrl) + .add("User-Agent", USER_AGENT) + + override val client: OkHttpClient = network.client.newBuilder() + .addInterceptor(::thumbnailIntercept) + .rateLimitHost(API_URL.toHttpUrl(), 1) + .rateLimitHost(baseUrl.toHttpUrl(), 2) + .build() + + private val json: Json by injectLazy() + + private var titleList: List? = null + + override fun popularMangaRequest(page: Int): Request { + return GET("$API_URL/search?format=json", headers) + } + + override fun popularMangaParse(response: Response): MangasPage { + titleList = response.parseAs().titles + + val titles = titleList!! + .sortedByDescending { it.bookmarkCount ?: 0 } + .map(MangaUpTitle::toSManga) + + return MangasPage(titles, hasNextPage = false) + } + + override fun latestUpdatesRequest(page: Int) = throw UnsupportedOperationException("Not used") + + override fun latestUpdatesParse(response: Response) = throw UnsupportedOperationException("Not used") + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + if (query.startsWith(PREFIX_ID_SEARCH) && query.matches(ID_SEARCH_PATTERN)) { + return titleDetailsRequest(query.removePrefix(PREFIX_ID_SEARCH)) + } + + val apiUrl = "$API_URL/manga/search".toHttpUrl().newBuilder() + .addQueryParameter("word", query) + .addQueryParameter("format", "json") + .toString() + + return GET(apiUrl, headers) + } + + override fun searchMangaParse(response: Response): MangasPage { + if (response.request.url.toString().contains("manga/detail")) { + val titleId = response.request.url.queryParameter("title_id")!! + + val title = response.parseAs().toSManga().apply { + url = "/manga/$titleId" + } + + return MangasPage(listOf(title), hasNextPage = false) + } + + val titles = response.parseAs().titles + + val query = response.request.url.queryParameter("word") + + if (query.isNullOrEmpty()) { + fetchAllTitles() + } else { + titleList = titles + } + + return MangasPage(titles.map(MangaUpTitle::toSManga), hasNextPage = false) + } + + private fun titleDetailsRequest(mangaUrl: String): Request { + val titleId = mangaUrl.substringAfterLast("/") + + val apiUrl = "$API_URL/manga/detail".toHttpUrl().newBuilder() + .addQueryParameter("title_id", titleId) + .addQueryParameter("ui_lang", lang) + .addQueryParameter("format", "json") + .toString() + + return GET(apiUrl, headers) + } + + // Workaround to allow "Open in browser" use the real URL. + override fun fetchMangaDetails(manga: SManga): Observable { + return client.newCall(titleDetailsRequest(manga.url)) + .asObservableSuccess() + .map { response -> + mangaDetailsParse(response).apply { initialized = true } + } + } + + override fun mangaDetailsParse(response: Response): SManga { + return response.parseAs().toSManga() + } + + override fun chapterListRequest(manga: SManga): Request = titleDetailsRequest(manga.url) + + override fun chapterListParse(response: Response): List { + val titleId = response.request.url.queryParameter("title_id")!!.toInt() + + return response.parseAs().readableChapters + .map { it.toSChapter(titleId) } + } + + override fun pageListRequest(chapter: SChapter): Request { + val chapterId = chapter.url.substringAfterLast("/") + + return GET("$API_URL/manga/viewer?chapter_id=$chapterId&format=json", headers) + } + + override fun pageListParse(response: Response): List { + return response.parseAs().pages + .mapIndexed { i, page -> Page(i, "", page.imageUrl) } + } + + override fun fetchImageUrl(page: Page): Observable = Observable.just(page.imageUrl!!) + + override fun imageUrlParse(response: Response): String = "" + + // Fetch all titles to get newer thumbnail URLs in the interceptor. + private fun fetchAllTitles() = runCatching { + val popularResponse = client.newCall(popularMangaRequest(1)).execute() + titleList = popularResponse.parseAs().titles + } + + private fun thumbnailIntercept(chain: Interceptor.Chain): Response { + val request = chain.request() + val response = chain.proceed(request) + + if (response.code == 401 && request.url.toString().contains(TITLE_THUMBNAIL_PATH)) { + val titleId = request.url.toString() + .substringAfter("/$TITLE_THUMBNAIL_PATH/") + .substringBefore(".webp") + .toInt() + val title = titleList?.find { it.id == titleId } ?: return response + + val thumbnailUrl = title.mainThumbnailUrl + ?: title.thumbnailUrl + ?: return response + + response.closeQuietly() + val thumbnailRequest = GET(thumbnailUrl, request.headers) + return chain.proceed(thumbnailRequest) + } + + return response + } + + private inline fun Response.parseAs(): T = use { + json.decodeFromString(body?.string().orEmpty()) + } + + companion object { + private const val API_URL = "https://global-web-api.manga-up.com/api" + private const val USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + + "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/103.0.0.0 Safari/537.36" + + private const val TITLE_THUMBNAIL_PATH = "manga_list" + + const val PREFIX_ID_SEARCH = "id:" + private val ID_SEARCH_PATTERN = "^id:(\\d+)$".toRegex() + } +} diff --git a/src/all/mangaup/src/eu/kanade/tachiyomi/extension/all/mangaup/MangaUpDto.kt b/src/all/mangaup/src/eu/kanade/tachiyomi/extension/all/mangaup/MangaUpDto.kt new file mode 100644 index 000000000..795972f2e --- /dev/null +++ b/src/all/mangaup/src/eu/kanade/tachiyomi/extension/all/mangaup/MangaUpDto.kt @@ -0,0 +1,97 @@ +package eu.kanade.tachiyomi.extension.all.mangaup + +import eu.kanade.tachiyomi.source.model.SChapter +import eu.kanade.tachiyomi.source.model.SManga +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import java.util.Date + +@Serializable +data class MangaUpSearch( + val titles: List = emptyList() +) + +@Serializable +data class MangaUpViewer( + val pages: List = emptyList() +) + +@Serializable +data class MangaUpTitle( + @SerialName("titleId") val id: Int? = null, + @SerialName("titleName") val name: String, + val authorName: String? = null, + val description: String? = null, + val copyright: String? = null, + val thumbnailUrl: String? = null, + val mainThumbnailUrl: String? = null, + val bookmarkCount: Int? = null, + val genres: List = emptyList(), + val chapters: List = emptyList() +) { + + private val fullDescription: String + get() = buildString { + description?.let { append(it) } + copyright?.let { append("\n\n" + it.replace("(C)", "© ")) } + } + + private val isFinished: Boolean + get() = chapters.any { it.mainName.contains("final chapter", ignoreCase = true) } + + val readableChapters: List + get() = chapters.filter(MangaUpChapter::isReadable).reversed() + + fun toSManga(): SManga = SManga.create().apply { + title = name + author = authorName + description = fullDescription.trim() + genre = genres.joinToString { it.name } + status = if (isFinished) SManga.COMPLETED else SManga.ONGOING + thumbnail_url = mainThumbnailUrl ?: thumbnailUrl + url = "/manga/$id" + } +} + +@Serializable +data class MangaUpGenre( + val id: Int, + val name: String +) + +@Serializable +data class MangaUpChapter( + val id: Int, + val mainName: String, + val subName: String? = null, + val price: Int? = null, + val published: Int, + val badge: MangaUpBadge = MangaUpBadge.FREE, + val available: Boolean = false +) { + + val isReadable: Boolean + get() = badge == MangaUpBadge.FREE && available + + fun toSChapter(titleId: Int): SChapter = SChapter.create().apply { + name = mainName.replace(WRONG_SPACING_REGEX, "-$1") + + if (!subName.isNullOrEmpty()) ": $subName" else "" + date_upload = (published * 1000L).takeIf { it <= Date().time } ?: 0L + url = "/manga/$titleId/$id" + } + + companion object { + private val WRONG_SPACING_REGEX = "\\s+-(\\d+)$".toRegex() + } +} + +enum class MangaUpBadge { + FREE, + ADVANCE, + UPDATE +} + +@Serializable +data class MangaUpPage( + val imageUrl: String +) diff --git a/src/all/mangaup/src/eu/kanade/tachiyomi/extension/all/mangaup/MangaUpFactory.kt b/src/all/mangaup/src/eu/kanade/tachiyomi/extension/all/mangaup/MangaUpFactory.kt new file mode 100644 index 000000000..146ca1e2d --- /dev/null +++ b/src/all/mangaup/src/eu/kanade/tachiyomi/extension/all/mangaup/MangaUpFactory.kt @@ -0,0 +1,16 @@ +package eu.kanade.tachiyomi.extension.all.mangaup + +import eu.kanade.tachiyomi.source.Source +import eu.kanade.tachiyomi.source.SourceFactory + +class MangaUpFactory : SourceFactory { + /** + * They only have English for now, but the website does have a + * language selector and the API also supports that. + * + * Probably it's something they will add in the future, so better + * to already make the extension a multilang to avoid users having + * to migrate to an All extension after. + */ + override fun createSources(): List = listOf(MangaUp("en")) +} diff --git a/src/all/mangaup/src/eu/kanade/tachiyomi/extension/all/mangaup/MangaUpUrlActivity.kt b/src/all/mangaup/src/eu/kanade/tachiyomi/extension/all/mangaup/MangaUpUrlActivity.kt new file mode 100644 index 000000000..81dffe7bc --- /dev/null +++ b/src/all/mangaup/src/eu/kanade/tachiyomi/extension/all/mangaup/MangaUpUrlActivity.kt @@ -0,0 +1,41 @@ +package eu.kanade.tachiyomi.extension.all.mangaup + +import android.app.Activity +import android.content.ActivityNotFoundException +import android.content.Intent +import android.os.Bundle +import android.util.Log +import kotlin.system.exitProcess + +class MangaUpUrlActivity : Activity() { + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val pathSegments = intent?.data?.pathSegments + if (pathSegments != null && pathSegments.size > 1) { + val query = pathSegments[1] + + if (query != null) { + val mainIntent = Intent().apply { + action = "eu.kanade.tachiyomi.SEARCH" + putExtra("query", MangaUp.PREFIX_ID_SEARCH + query) + putExtra("filter", packageName) + } + + try { + startActivity(mainIntent) + } catch (e: ActivityNotFoundException) { + Log.e("MangaPlusUrlActivity", e.toString()) + } + } else { + Log.e("MangaUpUrlActivity", "Missing the title ID from the URL") + } + } else { + Log.e("MangaUpUrlActivity", "Could not parse URI from intent $intent") + } + + finish() + exitProcess(0) + } +}