diff --git a/src/en/azuki/build.gradle b/src/en/azuki/build.gradle new file mode 100644 index 000000000..45c8850f2 --- /dev/null +++ b/src/en/azuki/build.gradle @@ -0,0 +1,8 @@ +ext { + extName = 'Azuki' + extClass = '.Azuki' + extVersionCode = 1 + isNsfw = true +} + +apply from: "$rootDir/common.gradle" diff --git a/src/en/azuki/res/mipmap-hdpi/ic_launcher.png b/src/en/azuki/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..3cb86b1b7 Binary files /dev/null and b/src/en/azuki/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/en/azuki/res/mipmap-mdpi/ic_launcher.png b/src/en/azuki/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..1c5a1d94c Binary files /dev/null and b/src/en/azuki/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/en/azuki/res/mipmap-xhdpi/ic_launcher.png b/src/en/azuki/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..214f67a67 Binary files /dev/null and b/src/en/azuki/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/en/azuki/res/mipmap-xxhdpi/ic_launcher.png b/src/en/azuki/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..a3fbd4c87 Binary files /dev/null and b/src/en/azuki/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/en/azuki/res/mipmap-xxxhdpi/ic_launcher.png b/src/en/azuki/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..b5918e0b9 Binary files /dev/null and b/src/en/azuki/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/en/azuki/src/eu/kanade/tachiyomi/extension/en/azuki/Azuki.kt b/src/en/azuki/src/eu/kanade/tachiyomi/extension/en/azuki/Azuki.kt new file mode 100644 index 000000000..fe4843db0 --- /dev/null +++ b/src/en/azuki/src/eu/kanade/tachiyomi/extension/en/azuki/Azuki.kt @@ -0,0 +1,220 @@ +package eu.kanade.tachiyomi.extension.en.azuki + +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.asObservable +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 eu.kanade.tachiyomi.util.asJsoup +import keiyoushi.utils.parseAs +import keiyoushi.utils.tryParse +import okhttp3.Headers +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.OkHttpClient +import okhttp3.Request +import okhttp3.Response +import org.jsoup.nodes.Element +import rx.Observable +import java.text.SimpleDateFormat +import java.util.Locale + +class Azuki : HttpSource() { + + override val name = "Azuki" + override val baseUrl = "https://www.azuki.co" + override val lang = "en" + override val supportsLatest = true + + private val apiUrl = "https://production.api.azuki.co" + private val organizationKey = "199e5a19-a236-49f5-81f4-43d4a541748a" + private val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US) + + override val client: OkHttpClient = network.client.newBuilder() + .addInterceptor(ImageInterceptor()) + .rateLimitHost(apiUrl.toHttpUrl(), 1) + .build() + + override fun headersBuilder() = super.headersBuilder() + .set("Referer", "$baseUrl/") + + // Popular + override fun popularMangaRequest(page: Int): Request { + return GET("$baseUrl/discover?sort=popular&page=$page", headers) + } + + override fun popularMangaParse(response: Response): MangasPage { + val document = response.asJsoup() + val mangas = document.select("ol.o-series-card-list li").map(::mangaFromElement) + val hasNextPage = document.selectFirst("a[rel=next]") != null + return MangasPage(mangas, hasNextPage) + } + + // Latest + override fun latestUpdatesRequest(page: Int): Request { + return GET("$baseUrl/discover?sort=recent_series&page=$page", headers) + } + + override fun latestUpdatesParse(response: Response): MangasPage = popularMangaParse(response) + + // Search + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + val url = "$baseUrl/discover".toHttpUrl().newBuilder() + url.addQueryParameter("q", query) + url.addQueryParameter("page", page.toString()) + + filters.forEach { filter -> + when (filter) { + is SortFilter -> url.addQueryParameter("sort", filter.toUriPart()) + is AccessTypeFilter -> filter.toUriPart().takeIf { it.isNotEmpty() }?.let { url.addQueryParameter("access_type", it) } + is GenreFilter -> + filter.state + .filter { it.state } + .forEach { url.addQueryParameter("tags[]", it.value) } + is PublisherFilter -> filter.toUriPart().takeIf { it.isNotEmpty() }?.let { url.addQueryParameter("publisher_slug", it) } + else -> {} + } + } + return GET(url.build(), headers) + } + + override fun searchMangaParse(response: Response): MangasPage = popularMangaParse(response) + + // Details + override fun mangaDetailsParse(response: Response): SManga { + val document = response.asJsoup() + return SManga.create().apply { + title = document.selectFirst("h1")!!.text() + author = document.selectFirst(".o-series-summary__byline p")?.text()?.substringAfter("By ")?.substringBefore(" Published by") + artist = author + description = document.selectFirst(".o-series-summary__description")?.text() + genre = document.select(".o-series-summary__genres a").joinToString { it.text() } + thumbnail_url = document.selectFirst(".o-series-summary__cover img")?.absUrl("src") + } + } + + // Chapters + override fun chapterListParse(response: Response): List { + val document = response.asJsoup() + val seriesSlug = response.request.url.pathSegments.lastOrNull() ?: return emptyList() + val mangaUuid = document.selectFirst("azuki-chapter-row-list[series-uuid]")?.attr("series-uuid") + + val unlockedChapterIds = if (mangaUuid != null) { + try { + val apiResponse = client.newCall(GET("$apiUrl/user/mangas/$mangaUuid/v0", apiHeaders())).execute() + if (apiResponse.isSuccessful) { + val result = apiResponse.parseAs() + (result.purchasedChapterUuids + result.unlockedChapterUuids).toSet() + } else { + emptySet() + } + } catch (e: Exception) { + emptySet() + } + } else { + emptySet() + } + + return document.select(".m-chapter-row-list .m-chapter-row").mapNotNull { chapterRow -> + val link = chapterRow.selectFirst("a.a-card-link") ?: return@mapNotNull null + val href = link.absUrl("href").toHttpUrl() + + val chapterId = if ("/checkout/" in href.encodedPath) { + href.queryParameter("chapter_uuids[]") + } else { + href.pathSegments.lastOrNull() + } + + if (chapterId.isNullOrEmpty()) return@mapNotNull null + + SChapter.create().apply { + url = "/series/$seriesSlug/read/$chapterId" + name = link.selectFirst(".m-chapter-row__title-cluster span")?.text() ?: link.text() + date_upload = dateFormat.tryParse(chapterRow.selectFirst(".m-chapter-row__date time")?.attr("datetime")) + + val isPremium = chapterRow.selectFirst(".m-chapter-row__premium-badge") != null || + chapterRow.parent()?.hasClass("m-chapter-card--secondary") == true + + if (isPremium && chapterId !in unlockedChapterIds) { + name = "🔒 $name" + } + } + }.reversed() + } + + // Pages + override fun pageListRequest(chapter: SChapter): Request { + val chapterId = chapter.url.substringAfter("/read/") + val apiUrl = "$apiUrl/chapters/$chapterId/pages/v1" + return GET(apiUrl, apiHeaders()) + } + + override fun fetchPageList(chapter: SChapter): Observable> { + return client.newCall(pageListRequest(chapter)) + .asObservable() + .map { response -> + if (!response.isSuccessful) { + if (response.code == 401 || response.code == 403) { + throw Exception("This chapter is locked. Log in via WebView and unlock the chapter to read.") + } + throw Exception("HTTP error ${response.code}") + } + pageListParse(response) + } + } + + override fun pageListParse(response: Response): List { + val result = response.parseAs() + return result.data.pages.mapIndexed { i, page -> + val imageList = page.image.webp ?: page.image.jpg + ?: throw Exception("No images found for page ${i + 1}") + + val bestAvailableUrl = imageList.maxByOrNull { it.width }?.url + ?: throw Exception("No image URL found for page ${i + 1}") + + val resolutionRegex = Regex("""/(\d+)\.(webp|jpg)$""") + val highResUrl = resolutionRegex.replace(bestAvailableUrl, "/2000.$2") + + Page(i, imageUrl = "$highResUrl?drm=1") + } + } + + private fun apiHeaders(): Headers { + val token = client.cookieJar.loadForRequest(baseUrl.toHttpUrl()) + .firstOrNull { it.name == "idToken" }?.value + + return headersBuilder() + .set("azuki-organization-key", organizationKey) + .apply { + if (token != null) { + set("x-user-token", token) + } + } + .build() + } + + // Filters + override fun getFilterList(): FilterList { + return FilterList( + SortFilter(), + AccessTypeFilter(), + PublisherFilter(), + GenreFilter(), + ) + } + + private fun mangaFromElement(element: Element): SManga { + return SManga.create().apply { + val link = element.selectFirst("a.a-card-link")!! + setUrlWithoutDomain(link.attr("href")) + title = link.text() + thumbnail_url = element.selectFirst("img")?.absUrl("src") + } + } + + // Unsupported + override fun imageUrlParse(response: Response): String = throw UnsupportedOperationException() +} diff --git a/src/en/azuki/src/eu/kanade/tachiyomi/extension/en/azuki/Dto.kt b/src/en/azuki/src/eu/kanade/tachiyomi/extension/en/azuki/Dto.kt new file mode 100644 index 000000000..16aa14291 --- /dev/null +++ b/src/en/azuki/src/eu/kanade/tachiyomi/extension/en/azuki/Dto.kt @@ -0,0 +1,39 @@ +package eu.kanade.tachiyomi.extension.en.azuki + +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable + +@Serializable +class PageListDto( + val data: PageDataDto, +) + +@Serializable +class PageDataDto( + val pages: List, +) + +@Serializable +class PageDto( + val image: ImageDto, +) + +@Serializable +class ImageDto( + val webp: List?, + val jpg: List?, +) + +@Serializable +class ImageUrlDto( + val url: String, + val width: Int, +) + +@Serializable +class UserMangaStatusDto( + @SerialName("purchased_chapter_uuids") + val purchasedChapterUuids: List = emptyList(), + @SerialName("unlocked_chapter_uuids") + val unlockedChapterUuids: List = emptyList(), +) diff --git a/src/en/azuki/src/eu/kanade/tachiyomi/extension/en/azuki/Genres.kt b/src/en/azuki/src/eu/kanade/tachiyomi/extension/en/azuki/Genres.kt new file mode 100644 index 000000000..4fdcd7617 --- /dev/null +++ b/src/en/azuki/src/eu/kanade/tachiyomi/extension/en/azuki/Genres.kt @@ -0,0 +1,87 @@ +package eu.kanade.tachiyomi.extension.en.azuki + +import eu.kanade.tachiyomi.source.model.Filter + +open class UriPartFilter(displayName: String, val vals: Array>) : + Filter.Select(displayName, vals.map { it.first }.toTypedArray()) { + fun toUriPart() = vals[state].second +} + +class CheckBox(name: String, val value: String) : Filter.CheckBox(name) + +class SortFilter : UriPartFilter( + "Sort by", + arrayOf( + Pair("Popular", "popular"), + Pair("Recent Series", "recent_series"), + Pair("Alphabetical", "alphabetical"), + ), +) + +class AccessTypeFilter : UriPartFilter( + "Access Type", + arrayOf( + Pair("Any", ""), + Pair("Premium", "fully_premium"), + Pair("Ebook", "purchasable"), + ), +) + +class PublisherFilter : UriPartFilter( + "Publisher", + arrayOf( + Pair("Any", ""), + Pair("ABLAZE", "ablaze"), + Pair("Azuki", "azuki"), + Pair("CLLENN", "cllenn"), + Pair("Coamix Inc.", "coamix"), + Pair("CORK", "cork"), + Pair("Futabasha Publishers Ltd.", "futabasha-publishers-ltd"), + Pair("Glacier Bay Books", "glacier-bay-books"), + Pair("J-Novel Club", "j-novel-club"), + Pair("KADOKAWA", "kadokawa"), + Pair("Kaiten Books", "kaiten-books"), + Pair("Kodansha", "kodansha"), + Pair("Manga Mavericks Books", "manga-mavericks-books"), + Pair("Manga Up!", "manga-up"), + Pair("One Peace Books", "one-peace-books"), + Pair("SOZO Comics", "sozo-comics"), + Pair("Star Fruit Books", "star-fruit-books"), + Pair("Toii Games (MediBang!)", "toii-games-medibang"), + Pair("TORICO (MediBang!)", "torico-medibang"), + Pair("Unknown", "unknown"), + Pair("VAST Visual", "vast-visual"), + Pair("YUZU Comics", "yuzu-comics"), + ), +) + +class GenreFilter : Filter.Group( + "Genres", + listOf( + CheckBox("Action", "action"), + CheckBox("Adventure", "adventure"), + CheckBox("Comedy", "comedy"), + CheckBox("Drama", "drama"), + CheckBox("Ecchi", "ecchi"), + CheckBox("Fantasy", "fantasy"), + CheckBox("Harem", "harem"), + CheckBox("Historical", "historical"), + CheckBox("Horror", "horror"), + CheckBox("Josei", "josei"), + CheckBox("Martial Arts", "martial-arts"), + CheckBox("Mature", "mature"), + CheckBox("Mecha", "mecha"), + CheckBox("Mystery", "mystery"), + CheckBox("Psychological", "psychological"), + CheckBox("Romance", "romance"), + CheckBox("School Life", "school-life"), + CheckBox("Sci-Fi", "scifi"), + CheckBox("Seinen", "seinen"), + CheckBox("Shojo", "shoujo"), + CheckBox("Shonen", "shounen"), + CheckBox("Slice of Life", "slice-of-life"), + CheckBox("Sports", "sports"), + CheckBox("Supernatural", "supernatural"), + CheckBox("Tragedy", "tragedy"), + ), +) diff --git a/src/en/azuki/src/eu/kanade/tachiyomi/extension/en/azuki/ImageInterceptor.kt b/src/en/azuki/src/eu/kanade/tachiyomi/extension/en/azuki/ImageInterceptor.kt new file mode 100644 index 000000000..5f9da45a7 --- /dev/null +++ b/src/en/azuki/src/eu/kanade/tachiyomi/extension/en/azuki/ImageInterceptor.kt @@ -0,0 +1,34 @@ +package eu.kanade.tachiyomi.extension.en.azuki + +import okhttp3.Interceptor +import okhttp3.Response +import okhttp3.ResponseBody.Companion.toResponseBody + +class ImageInterceptor : Interceptor { + + override fun intercept(chain: Interceptor.Chain): Response { + val request = chain.request() + if (!request.url.queryParameterNames.contains("drm")) { + return chain.proceed(request) + } + + val response = chain.proceed(request) + if (!response.isSuccessful) { + return response + } + + val encryptedBytes = response.body.bytes() + val decryptedBytes = decryptImage(encryptedBytes) + val decryptedBody = decryptedBytes.toResponseBody(response.body.contentType()) + + return response.newBuilder() + .body(decryptedBody) + .build() + } + +// https://www.azuki.co/assets/js/DecryptedImage.57631a1f.js + private fun decryptImage(encryptedData: ByteArray): ByteArray { + val keyByte = 174 + return encryptedData.map { (it.toInt() xor keyByte).toByte() }.toByteArray() + } +}