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 23876ad44..02f793677 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 @@ -25,6 +25,7 @@ import eu.kanade.tachiyomi.util.lang.runAsObservable import exh.md.MangaDexFabHeaderAdapter import exh.md.dto.MangaDto import exh.md.handlers.ApiMangaParser +import exh.md.handlers.BilibiliHandler import exh.md.handlers.ComikeyHandler import exh.md.handlers.FollowsHandler import exh.md.handlers.MangaHandler @@ -115,8 +116,19 @@ class MangaDex(delegate: HttpSource, val context: Context) : private val comikeyHandler by lazy { ComikeyHandler(network.cloudflareClient) } + private val bilibiliHandler by lazy { + BilibiliHandler(network.cloudflareClient) + } private val pageHandler by lazy { - PageHandler(headers, mangadexService, mangaPlusHandler, comikeyHandler, preferences, mdList) + PageHandler( + headers, + mangadexService, + mangaPlusHandler, + comikeyHandler, + bilibiliHandler, + preferences, + mdList + ) } // UrlImportableSource methods @@ -168,6 +180,12 @@ class MangaDex(delegate: HttpSource, val context: Context) : } } + override fun fetchImageUrl(page: Page): Observable { + return pageHandler.fetchImageUrl(page) { + super.fetchImageUrl(it) + } + } + // MetadataSource methods override val metaClass: KClass = MangaDexSearchMetadata::class diff --git a/app/src/main/java/exh/md/handlers/BilibiliHandler.kt b/app/src/main/java/exh/md/handlers/BilibiliHandler.kt new file mode 100644 index 000000000..ead4d317f --- /dev/null +++ b/app/src/main/java/exh/md/handlers/BilibiliHandler.kt @@ -0,0 +1,238 @@ +package exh.md.handlers + +import eu.kanade.tachiyomi.network.POST +import eu.kanade.tachiyomi.network.asObservableSuccess +import eu.kanade.tachiyomi.network.await +import eu.kanade.tachiyomi.network.interceptor.RateLimitInterceptor +import eu.kanade.tachiyomi.network.parseAs +import eu.kanade.tachiyomi.source.model.Page +import eu.kanade.tachiyomi.source.model.SChapter +import exh.log.xLogD +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.add +import kotlinx.serialization.json.buildJsonArray +import kotlinx.serialization.json.buildJsonObject +import kotlinx.serialization.json.put +import okhttp3.Headers +import okhttp3.MediaType.Companion.toMediaType +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.RequestBody.Companion.toRequestBody +import okhttp3.Response +import rx.Observable +import java.util.concurrent.TimeUnit + +class BilibiliHandler(currentClient: OkHttpClient) { + val baseUrl = "https://www.bilibilicomics.com" + val headers = Headers.Builder() + .add("Accept", ACCEPT_JSON) + .add("Origin", baseUrl) + .add("Referer", "$baseUrl/") + .build() + + val client: OkHttpClient = currentClient.newBuilder() + .addInterceptor(RateLimitInterceptor(1, 1, TimeUnit.SECONDS)) + .build() + + suspend fun fetchPageList(externalUrl: String, chapterNumber: String): List { + // Sometimes the urls direct it to the manga page instead, so we try to find the correct chapter + // Though these seem to be older chapters, so maybe remove this later + val chapterUrl = if (externalUrl.contains("mc\\d*/\\d*".toRegex())) { + getChapterUrl(externalUrl) + } else { + val mangaUrl = getMangaUrl(externalUrl) + val chapters = getChapterList(mangaUrl) + val chapter = chapters + .find { it.chapter_number == chapterNumber.toFloatOrNull() } + ?: throw Exception("Unknown chapter $chapterNumber") + chapter.url + } + + return fetchPageList(chapterUrl) + } + + private fun getMangaUrl(externalUrl: String): String { + xLogD(externalUrl) + val comicId = externalUrl + .substringAfter("/mc") + .substringBefore('?') + .toInt() + + return "/detail/mc$comicId" + } + + private fun getChapterUrl(externalUrl: String): String { + val comicId = externalUrl.substringAfterLast("/mc") + .substringBefore('/') + .toInt() + val episodeId = externalUrl.substringAfterLast('/') + .substringBefore('?') + .toInt() + return "/mc$comicId/$episodeId" + } + + private fun mangaDetailsApiRequest(mangaUrl: String): Request { + val comicId = mangaUrl.substringAfterLast("/mc").toInt() + + val jsonPayload = buildJsonObject { put("comic_id", comicId) } + val requestBody = jsonPayload.toString().toRequestBody(JSON_MEDIA_TYPE) + + val newHeaders = headers.newBuilder() + .add("Content-Length", requestBody.contentLength().toString()) + .add("Content-Type", requestBody.contentType().toString()) + .set("Referer", baseUrl + mangaUrl) + .build() + + return POST( + "$baseUrl/$BASE_API_ENDPOINT/ComicDetail?device=pc&platform=web", + headers = newHeaders, + body = requestBody + ) + } + + suspend fun getChapterList(mangaUrl: String): List { + val response = client.newCall(mangaDetailsApiRequest(mangaUrl)).await() + return chapterListParse(response) + } + + fun chapterListParse(response: Response): List { + val result = response.parseAs>() + + if (result.code != 0) { + return emptyList() + } + + return result.data!!.episodeList + .filter { episode -> episode.isLocked.not() } + .map { ep -> chapterFromObject(ep, result.data.id) } + } + + private fun chapterFromObject(episode: BilibiliEpisodeDto, comicId: Int): SChapter = SChapter.create().apply { + name = "Ep. " + episode.order.toString().removeSuffix(".0") + + " - " + episode.title + chapter_number = episode.order + url = "/mc$comicId/${episode.id}" + } + + private suspend fun fetchPageList(chapterUrl: String): List { + val response = client.newCall(pageListRequest(chapterUrl)).await() + return pageListParse(response) + } + + private fun pageListRequest(chapterUrl: String): Request { + val chapterId = chapterUrl.substringAfterLast("/").toInt() + + val jsonPayload = buildJsonObject { put("ep_id", chapterId) } + val requestBody = jsonPayload.toString().toRequestBody(JSON_MEDIA_TYPE) + + val newHeaders = headers + .newBuilder() + .add("Content-Length", requestBody.contentLength().toString()) + .add("Content-Type", requestBody.contentType().toString()) + .set("Referer", baseUrl + chapterUrl) + .build() + + return POST( + "$baseUrl/$BASE_API_ENDPOINT/GetImageIndex?device=pc&platform=web", + headers = newHeaders, + body = requestBody + ) + } + + private fun pageListParse(response: Response): List { + val result = response.parseAs>() + + if (result.code != 0) { + return emptyList() + } + + return result.data!!.images + .mapIndexed { i, page -> Page(i, page.path, "") } + } + + fun fetchImageUrl(page: Page): Observable { + return client.newCall(imageUrlRequest(page)) + .asObservableSuccess() + .map { + imageUrlParse(it) + } + } + + private fun imageUrlRequest(page: Page): Request { + val jsonPayload = buildJsonObject { + put("urls", buildJsonArray { add(page.url) }.toString()) + } + val requestBody = jsonPayload.toString().toRequestBody(JSON_MEDIA_TYPE) + + val newHeaders = headers.newBuilder() + .add("Content-Length", requestBody.contentLength().toString()) + .add("Content-Type", requestBody.contentType().toString()) + .build() + + return POST( + "$baseUrl/$BASE_API_ENDPOINT/ImageToken?device=pc&platform=web", + headers = newHeaders, + body = requestBody + ) + } + + private fun imageUrlParse(response: Response): String { + val result = response.parseAs>>() + val page = result.data!![0] + + return "${page.url}?token=${page.token}" + } + + @Serializable + data class BilibiliPageDto( + val token: String, + val url: String + ) + + @Serializable + data class BilibiliResultDto( + val code: Int = 0, + val data: T? = null, + @SerialName("msg") val message: String = "" + ) + + @Serializable + data class BilibiliReader( + val images: List = emptyList() + ) + + @Serializable + data class BilibiliImageDto( + val path: String + ) + + @Serializable + data class BilibiliComicDto( + @SerialName("author_name") val authorName: List = emptyList(), + @SerialName("classic_lines") val classicLines: String = "", + @SerialName("comic_id") val comicId: Int = 0, + @SerialName("ep_list") val episodeList: List = emptyList(), + val id: Int = 0, + @SerialName("is_finish") val isFinish: Int = 0, + @SerialName("season_id") val seasonId: Int = 0, + val styles: List = emptyList(), + val title: String, + @SerialName("vertical_cover") val verticalCover: String = "" + ) + + @Serializable + data class BilibiliEpisodeDto( + val id: Int, + @SerialName("is_locked") val isLocked: Boolean, + @SerialName("ord") val order: Float, + @SerialName("pub_time") val publicationTime: String, + val title: String + ) + + companion object { + private const val BASE_API_ENDPOINT = "twirp/comic.v1.Comic" + private const val ACCEPT_JSON = "application/json, text/plain, */*" + private val JSON_MEDIA_TYPE = "application/json;charset=UTF-8".toMediaType() + } +} diff --git a/app/src/main/java/exh/md/handlers/PageHandler.kt b/app/src/main/java/exh/md/handlers/PageHandler.kt index 6e196c226..7088c78ec 100644 --- a/app/src/main/java/exh/md/handlers/PageHandler.kt +++ b/app/src/main/java/exh/md/handlers/PageHandler.kt @@ -25,6 +25,7 @@ class PageHandler( private val service: MangaDexService, private val mangaPlusHandler: MangaPlusHandler, private val comikeyHandler: ComikeyHandler, + private val bilibiliHandler: BilibiliHandler, private val preferences: PreferencesHelper, private val mdList: MdList, ) { @@ -42,7 +43,11 @@ class PageHandler( chapter.scanlator.equals("comikey", true) -> comikeyHandler.fetchPageList( chapterResponse.data.attributes.externalUrl ) - else -> throw Exception("Chapter not supported") + chapter.scanlator.equals("bilibili comics", true) -> bilibiliHandler.fetchPageList( + chapterResponse.data.attributes.externalUrl, + chapterResponse.data.attributes.chapter.toString() + ) + else -> throw Exception("${chapter.scanlator} not supported") } } else { val headers = if (isLogged) { @@ -100,6 +105,7 @@ class PageHandler( } fun fetchImage(page: Page, superMethod: (Page) -> Observable): Observable { + xLogD(page.imageUrl) return when { page.imageUrl?.contains("mangaplus", true) == true -> { mangaPlusHandler.client.newCall(GET(page.imageUrl!!, headers)) @@ -109,6 +115,19 @@ class PageHandler( comikeyHandler.client.newCall(GET(page.imageUrl!!, comikeyHandler.headers)) .asObservableSuccess() } + page.imageUrl?.contains("/bfs/comic/", true) == true -> { + bilibiliHandler.client.newCall(GET(page.imageUrl!!, bilibiliHandler.headers)) + .asObservableSuccess() + } + else -> superMethod(page) + } + } + + fun fetchImageUrl(page: Page, superMethod: (Page) -> Observable): Observable { + return when { + page.url.contains("/bfs/comic/") -> { + bilibiliHandler.fetchImageUrl(page) + } else -> superMethod(page) } }