diff --git a/src/en/atsumaru/build.gradle b/src/en/atsumaru/build.gradle new file mode 100644 index 000000000..1ca955c27 --- /dev/null +++ b/src/en/atsumaru/build.gradle @@ -0,0 +1,8 @@ +ext { + extName = 'Atsumaru' + extClass = '.Atsumaru' + extVersionCode = 1 + isNsfw = true +} + +apply from: "$rootDir/common.gradle" diff --git a/src/en/atsumaru/res/mipmap-hdpi/ic_launcher.png b/src/en/atsumaru/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..234a74598 Binary files /dev/null and b/src/en/atsumaru/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/en/atsumaru/res/mipmap-mdpi/ic_launcher.png b/src/en/atsumaru/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..05e217e72 Binary files /dev/null and b/src/en/atsumaru/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/en/atsumaru/res/mipmap-xhdpi/ic_launcher.png b/src/en/atsumaru/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..afe5c9ca8 Binary files /dev/null and b/src/en/atsumaru/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/en/atsumaru/res/mipmap-xxhdpi/ic_launcher.png b/src/en/atsumaru/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..d092b541f Binary files /dev/null and b/src/en/atsumaru/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/en/atsumaru/res/mipmap-xxxhdpi/ic_launcher.png b/src/en/atsumaru/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..3f671a67e Binary files /dev/null and b/src/en/atsumaru/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/en/atsumaru/src/eu/kanade/tachiyomi/extension/en/atsumaru/Atsumaru.kt b/src/en/atsumaru/src/eu/kanade/tachiyomi/extension/en/atsumaru/Atsumaru.kt new file mode 100644 index 000000000..8d7a5b55e --- /dev/null +++ b/src/en/atsumaru/src/eu/kanade/tachiyomi/extension/en/atsumaru/Atsumaru.kt @@ -0,0 +1,156 @@ +package eu.kanade.tachiyomi.extension.en.atsumaru + +import eu.kanade.tachiyomi.network.GET +import eu.kanade.tachiyomi.network.interceptor.rateLimit +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.HttpUrl.Companion.toHttpUrl +import okhttp3.Request +import okhttp3.Response +import uy.kohesive.injekt.injectLazy + +class Atsumaru : HttpSource() { + + override val name = "Atsumaru" + + override val baseUrl = "https://atsu.moe" + private val apiUrl = "$baseUrl/api/v1" + + override val lang = "en" + + override val supportsLatest = true + + override val client = network.cloudflareClient.newBuilder() + .rateLimit(2) + .build() + + override fun headersBuilder() = super.headersBuilder() + .add("Referer", "$baseUrl/") + + private fun apiHeadersBuilder() = headersBuilder().apply { + add("Accept", "*/*") + add("Host", apiUrl.toHttpUrl().host) + } + + private val apiHeaders by lazy { apiHeadersBuilder().build() } + + private val json: Json by injectLazy() + + // ============================== Popular =============================== + + override fun popularMangaRequest(page: Int): Request { + return GET("$apiUrl/layouts/s1/sliders/hotUpdates", apiHeaders) + } + + override fun popularMangaParse(response: Response): MangasPage { + val data = response.parseAs().items + + return MangasPage(data.map { it.manga.toSManga() }, false) + } + + // =============================== Latest =============================== + + override fun latestUpdatesRequest(page: Int): Request { + return GET("$apiUrl/layouts/s1/latest-updates", apiHeaders) + } + + override fun latestUpdatesParse(response: Response): MangasPage { + return popularMangaParse(response) + } + + // =============================== Search =============================== + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + val url = "$apiUrl/search".toHttpUrl().newBuilder() + .addPathSegment(query) + .build() + + return GET(url, apiHeaders) + } + + override fun searchMangaParse(response: Response): MangasPage { + val data = response.parseAs().hits + + return MangasPage(data.map { it.info.toSManga() }, false) + } + + // =========================== Manga Details ============================ + + override fun getMangaUrl(manga: SManga): String { + return baseUrl + manga.url + } + + override fun mangaDetailsRequest(manga: SManga): Request { + return GET(apiUrl + manga.url, apiHeaders) + } + + override fun mangaDetailsParse(response: Response): SManga { + return response.parseAs().manga.toSManga() + } + + // ============================== Chapters ============================== + + override fun chapterListRequest(manga: SManga): Request { + return mangaDetailsRequest(manga) + } + + override fun chapterListParse(response: Response): List { + val chapterList = response.parseAs().manga.chapters!!.map { + it.toSChapter(response.request.url.pathSegments.last()) + } + + return chapterList.sortedWith( + compareBy( + { it.chapter_number }, + { it.scanlator }, + ), + ).reversed() + } + + override fun getChapterUrl(chapter: SChapter): String { + val (slug, name) = chapter.url.split("/") + return "$baseUrl/read/s1/$slug/$name/1" + } + + // =============================== Pages ================================ + + override fun pageListRequest(chapter: SChapter): Request { + val (slug, name) = chapter.url.split("/") + return GET("$apiUrl/manga/s1/$slug#$name", apiHeaders) + } + + override fun pageListParse(response: Response): List { + val chapter = response.parseAs().manga.chapters!!.first { + it.name == response.request.url.fragment + } + + return chapter.pages.map { page -> + Page(page.name.toInt(), imageUrl = page.pageURLs.first()) + }.sortedBy { it.index } + } + + override fun imageRequest(page: Page): Request { + val imgHeaders = headersBuilder().apply { + add("Accept", "image/avif,image/webp,*/*") + add("Host", page.imageUrl!!.toHttpUrl().host) + }.build() + + return GET(page.imageUrl!!, imgHeaders) + } + + override fun imageUrlParse(response: Response): String { + throw UnsupportedOperationException() + } + + // ============================= Utilities ============================== + + private inline fun Response.parseAs(): T { + return json.decodeFromString(body.string()) + } +} diff --git a/src/en/atsumaru/src/eu/kanade/tachiyomi/extension/en/atsumaru/Dto.kt b/src/en/atsumaru/src/eu/kanade/tachiyomi/extension/en/atsumaru/Dto.kt new file mode 100644 index 000000000..5928fdeb7 --- /dev/null +++ b/src/en/atsumaru/src/eu/kanade/tachiyomi/extension/en/atsumaru/Dto.kt @@ -0,0 +1,115 @@ +package eu.kanade.tachiyomi.extension.en.atsumaru + +import eu.kanade.tachiyomi.source.model.SChapter +import eu.kanade.tachiyomi.source.model.SManga +import kotlinx.serialization.Serializable +import java.text.ParseException +import java.text.SimpleDateFormat +import java.util.Locale + +@Serializable +class BrowseMangaDto( + val items: List, +) + +@Serializable +class MangaObjectDto( + val manga: MangaDto, +) + +@Serializable +class SearchResultsDto( + val hits: List, +) { + @Serializable + class SearchMangaDto( + val info: MangaDto, + ) +} + +@Serializable +class MangaDto( + // Common + private val title: String, + private val cover: String, + private val slug: String, + + // Details + private val authors: List? = null, + private val description: String? = null, + private val genres: List? = null, + private val statuses: List? = null, + + // Chapters + val chapters: List? = null, +) { + fun toSManga(): SManga = SManga.create().apply { + title = this@MangaDto.title + thumbnail_url = cover + url = "/manga/s1/$slug" + + authors?.let { + author = it.joinToString() + } + description = this@MangaDto.description + genres?.let { + genre = it.joinToString() + } + statuses?.let { + status = when (it.first().lowercase().substringBefore(" ")) { + "ongoing" -> SManga.ONGOING + "complete" -> SManga.COMPLETED + else -> SManga.UNKNOWN + } + } + } +} + +@Serializable +class ChapterDto( + val pages: List, + val name: String, + private val type: String, + private val title: String? = null, + private val date: String? = null, +) { + fun toSChapter(slug: String): SChapter = SChapter.create().apply { + val chapterNumber = this@ChapterDto.name.replace("_", ".") + .filter { it.isDigit() || it == '.' } + + name = buildString { + append("Chapter ") + append(chapterNumber) + if (title != null) { + append(" - ") + append(title) + } + } + url = "$slug/${this@ChapterDto.name}" + chapter_number = chapterNumber.toFloat() + scanlator = type.takeUnless { it == "Chapter" } + date?.let { + date_upload = parseDate(it) + } + } + + private fun parseDate(dateStr: String): Long { + return try { + DATE_FORMAT.parse(dateStr)!!.time + } catch (_: ParseException) { + 0L + } + } + + companion object { + private val DATE_FORMAT by lazy { + SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.ENGLISH) + } + } +} + +@Serializable +class PageDto( + val pageURLs: List, + val name: String, +)