diff --git a/src/en/weebdex/build.gradle b/src/en/weebdex/build.gradle new file mode 100644 index 000000000..664f1abff --- /dev/null +++ b/src/en/weebdex/build.gradle @@ -0,0 +1,7 @@ +ext { + extName = 'WeebDex' + extClass = '.WeebDex' + extVersionCode = 1 + isNsfw = true +} +apply from: "$rootDir/common.gradle" diff --git a/src/en/weebdex/res/mipmap-hdpi/ic_launcher.png b/src/en/weebdex/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 000000000..705665d32 Binary files /dev/null and b/src/en/weebdex/res/mipmap-hdpi/ic_launcher.png differ diff --git a/src/en/weebdex/res/mipmap-mdpi/ic_launcher.png b/src/en/weebdex/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 000000000..be9f3ba80 Binary files /dev/null and b/src/en/weebdex/res/mipmap-mdpi/ic_launcher.png differ diff --git a/src/en/weebdex/res/mipmap-xhdpi/ic_launcher.png b/src/en/weebdex/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 000000000..bc2249daa Binary files /dev/null and b/src/en/weebdex/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/src/en/weebdex/res/mipmap-xxhdpi/ic_launcher.png b/src/en/weebdex/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 000000000..eb4e78b07 Binary files /dev/null and b/src/en/weebdex/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/src/en/weebdex/res/mipmap-xxxhdpi/ic_launcher.png b/src/en/weebdex/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 000000000..7657316d1 Binary files /dev/null and b/src/en/weebdex/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/src/en/weebdex/src/eu/kanade/tachiyomi/extension/en/weebdex/WeebDex.kt b/src/en/weebdex/src/eu/kanade/tachiyomi/extension/en/weebdex/WeebDex.kt new file mode 100644 index 000000000..0d1eddac0 --- /dev/null +++ b/src/en/weebdex/src/eu/kanade/tachiyomi/extension/en/weebdex/WeebDex.kt @@ -0,0 +1,173 @@ +package eu.kanade.tachiyomi.extension.en.weebdex + +import eu.kanade.tachiyomi.extension.en.weebdex.dto.ChapterDto +import eu.kanade.tachiyomi.extension.en.weebdex.dto.ChapterListDto +import eu.kanade.tachiyomi.extension.en.weebdex.dto.MangaDto +import eu.kanade.tachiyomi.extension.en.weebdex.dto.MangaListDto +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 keiyoushi.utils.parseAs +import okhttp3.Headers +import okhttp3.HttpUrl.Companion.toHttpUrl +import okhttp3.Request +import okhttp3.Response + +class WeebDex : HttpSource() { + override val name = "WeebDex" + override val baseUrl = "https://weebdex.org" + override val lang = "en" + override val supportsLatest = true + + override val client = network.cloudflareClient.newBuilder() + .rateLimit(WeebDexConstants.RATE_LIMIT) + .build() + + override fun headersBuilder(): Headers.Builder = super.headersBuilder() + .add("Referer", "$baseUrl/") + + // -------------------- Popular -------------------- + + override fun popularMangaRequest(page: Int): Request { + val url = WeebDexConstants.API_MANGA_URL.toHttpUrl().newBuilder() + .addQueryParameter("page", page.toString()) + .addQueryParameter("sort", "views") + .addQueryParameter("order", "desc") + .addQueryParameter("hasChapters", "1") + .build() + return GET(url, headers) + } + + override fun popularMangaParse(response: Response): MangasPage { + val mangaListDto = response.parseAs() + val mangas = mangaListDto.toSMangaList() + return MangasPage(mangas, mangaListDto.hasNextPage) + } + + // -------------------- Latest -------------------- + override fun latestUpdatesRequest(page: Int): Request { + val url = WeebDexConstants.API_MANGA_URL.toHttpUrl().newBuilder() + .addQueryParameter("page", page.toString()) + .addQueryParameter("sort", "updatedAt") + .addQueryParameter("order", "desc") + .addQueryParameter("hasChapters", "1") + .build() + return GET(url, headers) + } + + override fun latestUpdatesParse(response: Response): MangasPage = popularMangaParse(response) + + // -------------------- Search -------------------- + override fun getFilterList(): FilterList = buildFilterList() + + override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { + val urlBuilder = WeebDexConstants.API_MANGA_URL.toHttpUrl().newBuilder() + .addQueryParameter("page", page.toString()) + + if (query.isNotBlank()) { + urlBuilder.addQueryParameter("title", query) + } else { + filters.forEach { filter -> + when (filter) { + is TagList -> { + filter.state.forEach { tag -> + if (tag.state) { + WeebDexConstants.tags[tag.name]?.let { tagId -> + urlBuilder.addQueryParameter("tag", tagId) + } + } + } + } + is TagsExcludeFilter -> { + filter.state.forEach { tag -> + if (tag.state) { + WeebDexConstants.tags[tag.name]?.let { tagId -> + urlBuilder.addQueryParameter("tagx", tagId) + } + } + } + } + is TagModeFilter -> urlBuilder.addQueryParameter("tmod", filter.state.toString()) + is TagExcludeModeFilter -> urlBuilder.addQueryParameter("txmod", filter.state.toString()) + else -> { /* Do Nothing */ } + } + } + } + + // Separated explicitly to be applied even when a search query is applied. + filters.forEach { filter -> + when (filter) { + is SortFilter -> urlBuilder.addQueryParameter("sort", filter.selected) + is OrderFilter -> urlBuilder.addQueryParameter("order", filter.selected) + is StatusFilter -> filter.selected?.let { urlBuilder.addQueryParameter("status", it) } + is DemographicFilter -> filter.selected?.let { urlBuilder.addQueryParameter("demographic", it) } + is ContentRatingFilter -> filter.selected?.let { urlBuilder.addQueryParameter("contentRating", it) } + is LangFilter -> filter.query?.let { urlBuilder.addQueryParameter("lang", it) } + is HasChaptersFilter -> if (filter.state) urlBuilder.addQueryParameter("hasChapters", "1") + is YearFromFilter -> filter.state.takeIf { it.isNotEmpty() }?.let { urlBuilder.addQueryParameter("yearFrom", it) } + is YearToFilter -> filter.state.takeIf { it.isNotEmpty() }?.let { urlBuilder.addQueryParameter("yearTo", it) } + else -> { /* Do Nothing */ } + } + } + + return GET(urlBuilder.build(), headers) + } + + override fun searchMangaParse(response: Response): MangasPage = popularMangaParse(response) + + // -------------------- Manga details -------------------- + + override fun mangaDetailsRequest(manga: SManga): Request { + return GET("${WeebDexConstants.API_URL}${manga.url}", headers) + } + + override fun mangaDetailsParse(response: Response): SManga { + val manga = response.parseAs() + return manga.toSManga() + } + + // -------------------- Chapters -------------------- + + override fun chapterListRequest(manga: SManga): Request { + // chapter list is paginated; get all pages + return GET("${WeebDexConstants.API_URL}${manga.url}/chapters?order=desc", headers) + } + + override fun chapterListParse(response: Response): List { + val chapters = mutableListOf() + + // Recursively parse pages + fun parsePage(chapterListDto: ChapterListDto) { + chapters.addAll(chapterListDto.toSChapterList()) + if (chapterListDto.hasNextPage) { + val nextUrl = response.request.url.newBuilder() + .setQueryParameter("page", (chapterListDto.page + 1).toString()) + .build() + val nextResponse = client.newCall(GET(nextUrl, headers)).execute() + val nextChapterListDto = nextResponse.parseAs() + parsePage(nextChapterListDto) + } + } + + parsePage(response.parseAs()) + return chapters + } + + // -------------------- Pages -------------------- + + override fun pageListRequest(chapter: SChapter): Request { + return GET("${WeebDexConstants.API_URL}${chapter.url}", headers) + } + + override fun pageListParse(response: Response): List { + val chapter = response.parseAs() + return chapter.toPageList() + } + + override fun imageUrlParse(response: Response): String = throw UnsupportedOperationException("Not used") +} diff --git a/src/en/weebdex/src/eu/kanade/tachiyomi/extension/en/weebdex/WeebDexConstants.kt b/src/en/weebdex/src/eu/kanade/tachiyomi/extension/en/weebdex/WeebDexConstants.kt new file mode 100644 index 000000000..8a98869fd --- /dev/null +++ b/src/en/weebdex/src/eu/kanade/tachiyomi/extension/en/weebdex/WeebDexConstants.kt @@ -0,0 +1,109 @@ +package eu.kanade.tachiyomi.extension.en.weebdex + +object WeebDexConstants { + + // API Base URLs + const val API_URL = "https://api.weebdex.org" + const val CDN_URL = "https://srv.notdelta.xyz" + + // API Endpoints + const val API_MANGA_URL = "$API_URL/manga" + + // CDN Endpoints + const val CDN_COVER_URL = "$CDN_URL/covers" + const val CDN_DATA_URL = "$CDN_URL/data" + + // Rate Limit (API is 5 req/s, using conservative value) + const val RATE_LIMIT = 3 + + // Tags Map + val tags = mapOf( + // Formats + "Oneshot" to "99q3m1plnt", + "Web Comic" to "1utcekkc70", + "Doujinshi" to "fnvjk3jg1b", + "Adaptation" to "pbst9p8bd4", + "Full Color" to "6amsrv3w16", + "4-Koma" to "jnqtucy8q3", + + // Genres + "Action" to "g0eao31zjw", + "Adventure" to "pjl8oxd1ld", + "Boys' Love" to "1cnfhxwshb", + "Comedy" to "onj03z2gnf", + "Crime" to "bwec51tbms", + "Drama" to "00xq9oqthh", + "Fantasy" to "3lhj8r7s6n", + "Girls' Love" to "i9w6sjikyd", + "Historical" to "mmf28hr2co", + "Horror" to "rclreo8b25", + "Magical Girls" to "hy189x450f", + "Mystery" to "hv0hsu8kje", + "Romance" to "o0rm4pweru", + "Slice of Life" to "13x7xvq10k", + "Sports" to "zsvyg4whkp", + "Tragedy" to "85hmqw16y9", + + // Themes + "Cooking" to "9wm2j2zl1e", + "Crossdressing" to "arjr4qdpgc", + "Delinquents" to "h5ioz14hix", + "Genderswap" to "25k4gcfnfp", + "Magic" to "evt7r78scn", + "Monster Girls" to "ddjrvi8vsu", + "School Life" to "hobsiukk71", + "Shota" to "lu0sbwbs3r", + "Supernatural" to "c4rnaci8q6", + "Traditional Games" to "aqfqkul8rg", + "Vampires" to "djs29flsq6", + "Video Games" to "axstzcu7pc", + "Office Workers" to "6uytt2873o", + "Martial Arts" to "577a4hd52b", + "Zombies" to "szg24cwbrm", + "Survival" to "mt4vdanhfc", + "Police" to "acai4usl79", + "Mafia" to "qjuief8bi1", + + // Content Tags + "Gore" to "hceia50cf9", + "Sexual Violence" to "xh9k4t31ll", + ) + + // Demographics + val demographics = listOf( + "Any" to null, + "Shounen" to "shounen", + "Shoujo" to "shoujo", + "Josei" to "josei", + "Seinen" to "seinen", + ) + + // Publication Status + val statusList = listOf( + "Any" to null, + "Ongoing" to "ongoing", + "Completed" to "completed", + "Hiatus" to "hiatus", + "Cancelled" to "cancelled", + ) + + // Languages + val langList = listOf( + "Any" to null, + "English" to "en", + "Japanese" to "ja", + ) + + // Sort Options + val sortList = listOf( + "Views" to "views", + "Updated At" to "updatedAt", + "Created At" to "createdAt", + "Chapter Update" to "lastUploadedChapterAt", + "Title" to "title", + "Year" to "year", + "Rating" to "rating", + "Follows" to "follows", + "Chapters" to "chapters", + ) +} diff --git a/src/en/weebdex/src/eu/kanade/tachiyomi/extension/en/weebdex/WeebDexFilters.kt b/src/en/weebdex/src/eu/kanade/tachiyomi/extension/en/weebdex/WeebDexFilters.kt new file mode 100644 index 000000000..09a9b66db --- /dev/null +++ b/src/en/weebdex/src/eu/kanade/tachiyomi/extension/en/weebdex/WeebDexFilters.kt @@ -0,0 +1,113 @@ +package eu.kanade.tachiyomi.extension.en.weebdex + +import eu.kanade.tachiyomi.source.model.Filter +import eu.kanade.tachiyomi.source.model.FilterList + +internal fun buildFilterList() = FilterList( + Filter.Header("NOTE: Not all filters work on all sources."), + Filter.Separator(), + SortFilter(), + OrderFilter(), + StatusFilter(), + LangFilter(), + DemographicFilter(), + HasChaptersFilter(), + Filter.Header("Tags (ignored if searching by text)"), + TagModeFilter(), + TagList(WeebDexConstants.tags.keys.toTypedArray()), + Filter.Header("Tags to exclude (ignored if searching by text)"), + TagExcludeModeFilter(), + TagsExcludeFilter(WeebDexConstants.tags.keys.toTypedArray()), + ContentRatingFilter(), + YearFromFilter(), + YearToFilter(), +) + +internal class SortFilter : Filter.Select( + "Sort by", + WeebDexConstants.sortList.map { it.first }.toTypedArray(), + 0, +) { + val selected: String + get() = WeebDexConstants.sortList[state].second +} + +internal class OrderFilter : Filter.Select( + "Order", + arrayOf("Descending", "Ascending"), + 0, +) { + val selected: String + get() = if (state == 0) "desc" else "asc" +} + +internal class StatusFilter : Filter.Select( + "Status", + WeebDexConstants.statusList.map { it.first }.toTypedArray(), + 0, +) { + val selected: String? + get() = WeebDexConstants.statusList[state].second +} + +class TagCheckBox(name: String) : Filter.CheckBox(name, false) +class TagList(tags: Array) : Filter.Group("Tags", tags.map { TagCheckBox(it) }) +class TagsExcludeFilter(tags: Array) : Filter.Group( + "Tags to Exclude", + tags.map { TagCheckBox(it) }, +) + +class TagModeFilter : Filter.Select( + "Tag mode", + arrayOf("AND", "OR"), // what user sees + 0, +) { + val selected: String + get() = if (state == 0) "0" else "1" // backend wants 0=AND, 1=OR +} + +class TagExcludeModeFilter : Filter.Select( + "Exclude tag mode", + arrayOf("OR", "AND"), // what user sees + 0, +) { + val selected: String + get() = if (state == 0) "0" else "1" // backend wants 0=OR, 1=AND +} + +internal class DemographicFilter : Filter.Select( + "Demographic", + WeebDexConstants.demographics.map { it.first }.toTypedArray(), + 0, +) { + val selected: String? + get() = WeebDexConstants.demographics[state].second +} + +internal class ContentRatingFilter : Filter.Select( + "Content Rating", + arrayOf("Any", "Safe", "Suggestive", "Erotica", "Pornographic"), + 0, +) { + private val apiValues = arrayOf(null, "safe", "suggestive", "erotica", "pornographic") + + val selected: String? + get() = apiValues[state] +} + +internal class LangFilter : Filter.Select( + "Original Language", + WeebDexConstants.langList.map { it.first }.toTypedArray(), + 0, +) { + val query: String? + get() = WeebDexConstants.langList[state].second +} + +internal class HasChaptersFilter : Filter.CheckBox("Has Chapters", true) { + val selected: String? + get() = if (state) "1" else null +} + +internal class YearFromFilter : Filter.Text("Year (from)") +internal class YearToFilter : Filter.Text("Year (to)") diff --git a/src/en/weebdex/src/eu/kanade/tachiyomi/extension/en/weebdex/WeebDexHelper.kt b/src/en/weebdex/src/eu/kanade/tachiyomi/extension/en/weebdex/WeebDexHelper.kt new file mode 100644 index 000000000..49d6db75b --- /dev/null +++ b/src/en/weebdex/src/eu/kanade/tachiyomi/extension/en/weebdex/WeebDexHelper.kt @@ -0,0 +1,31 @@ +import eu.kanade.tachiyomi.extension.en.weebdex.WeebDexConstants +import eu.kanade.tachiyomi.source.model.SManga +import keiyoushi.utils.tryParse +import java.text.SimpleDateFormat +import java.util.Locale +import java.util.TimeZone + +class WeebDexHelper { + private val dateFormat = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.ENGLISH).apply { + timeZone = TimeZone.getTimeZone("UTC") + } + fun parseStatus(status: String?): Int { + return when (status?.lowercase(Locale.ROOT)) { + "ongoing" -> SManga.ONGOING + "completed" -> SManga.COMPLETED + "hiatus" -> SManga.ON_HIATUS + "cancelled" -> SManga.CANCELLED + else -> SManga.UNKNOWN + } + } + + fun buildCoverUrl(mangaId: String, cover: eu.kanade.tachiyomi.extension.en.weebdex.dto.CoverDto?): String? { + if (cover == null) return null + val ext = cover.ext + return "${WeebDexConstants.CDN_COVER_URL}/$mangaId/${cover.id}$ext" + } + + fun parseDate(dateStr: String): Long { + return dateFormat.tryParse(dateStr) + } +} diff --git a/src/en/weebdex/src/eu/kanade/tachiyomi/extension/en/weebdex/dto/ChapterDto.kt b/src/en/weebdex/src/eu/kanade/tachiyomi/extension/en/weebdex/dto/ChapterDto.kt new file mode 100644 index 000000000..72c680ab0 --- /dev/null +++ b/src/en/weebdex/src/eu/kanade/tachiyomi/extension/en/weebdex/dto/ChapterDto.kt @@ -0,0 +1,101 @@ +package eu.kanade.tachiyomi.extension.en.weebdex.dto + +import WeebDexHelper +import eu.kanade.tachiyomi.extension.en.weebdex.WeebDexConstants +import eu.kanade.tachiyomi.source.model.Page +import eu.kanade.tachiyomi.source.model.SChapter +import kotlinx.serialization.Contextual +import kotlinx.serialization.SerialName +import kotlinx.serialization.Serializable +import org.jsoup.parser.Parser + +@Serializable +class ChapterListDto( + private val data: List = emptyList(), + val page: Int = 1, + val limit: Int = 0, + val total: Int = 0, +) { + val hasNextPage: Boolean + get() = page * limit < total + fun toSChapterList(): List { + return data.map { it.toSChapter() } + } +} + +@Serializable +class ChapterDto( + private val id: String, + private val title: String? = null, + private val chapter: String? = null, + private val volume: String? = null, + @SerialName("published_at") private val publishedAt: String = "", + private val data: List? = null, + @SerialName("data_optimized") private val dataOptimized: List? = null, + private val relationships: ChapterRelationshipsDto? = null, +) { + @Contextual + private val helper = WeebDexHelper() + + fun toSChapter(): SChapter { + val chapterName = mutableListOf() + // Build chapter name + volume?.let { + if (it.isNotEmpty()) { + chapterName.add("Vol.$it") + } + } + + chapter?.let { + if (it.isNotEmpty()) { + chapterName.add("Ch.$it") + } + } + + title?.let { + if (it.isNotEmpty()) { + if (chapterName.isNotEmpty()) { + chapterName.add("-") + } + chapterName.add(it) + } + } + + // if volume, chapter and title is empty its a oneshot + if (chapterName.isEmpty()) { + chapterName.add("Oneshot") + } + + return SChapter.create().apply { + url = "/chapter/$id" + name = Parser.unescapeEntities(chapterName.joinToString(" "), false) + chapter_number = chapter?.toFloat() ?: -2F + date_upload = helper.parseDate(publishedAt) + scanlator = relationships?.groups?.joinToString(", ") { it.name } + } + } + fun toPageList(): List { + val pagesArray = dataOptimized ?: data ?: emptyList() + val pages = mutableListOf() + + pagesArray.forEachIndexed { index, pageData -> + // pages in spec have 'name' field and images served from /data/{id}/{filename} + val filename = pageData.name + val chapterId = id + val imageUrl = filename?.takeIf { it.isNotBlank() && chapterId.isNotBlank() } + ?.let { "${WeebDexConstants.CDN_DATA_URL}/$chapterId/$it" } + pages.add(Page(index, imageUrl = imageUrl)) + } + return pages + } +} + +@Serializable +class ChapterRelationshipsDto( + val groups: List = emptyList(), +) + +@Serializable +class PageData( + val name: String? = null, +) diff --git a/src/en/weebdex/src/eu/kanade/tachiyomi/extension/en/weebdex/dto/MangaDto.kt b/src/en/weebdex/src/eu/kanade/tachiyomi/extension/en/weebdex/dto/MangaDto.kt new file mode 100644 index 000000000..88d3cca2b --- /dev/null +++ b/src/en/weebdex/src/eu/kanade/tachiyomi/extension/en/weebdex/dto/MangaDto.kt @@ -0,0 +1,65 @@ +package eu.kanade.tachiyomi.extension.en.weebdex.dto + +import WeebDexHelper +import eu.kanade.tachiyomi.source.model.SManga +import kotlinx.serialization.Contextual +import kotlinx.serialization.Serializable + +@Serializable +class MangaListDto( + private val data: List = emptyList(), + val page: Int = 1, + val limit: Int = 0, + val total: Int = 0, +) { + val hasNextPage: Boolean + get() = page * limit < total + fun toSMangaList(): List { + return data.map { it.toSManga() } + } +} + +@Serializable +class MangaDto( + private val id: String, + private val title: String, + private val description: String = "", + private val status: String? = null, + val relationships: RelationshipsDto? = null, +) { + @Contextual + private val helper = WeebDexHelper() + fun toSManga(): SManga { + return SManga.create().apply { + title = this@MangaDto.title + description = this@MangaDto.description + status = helper.parseStatus(this@MangaDto.status) + thumbnail_url = helper.buildCoverUrl(id, relationships?.cover) + url = "/manga/$id" + relationships?.let { rel -> + author = rel.authors.joinToString(", ") { it.name } + artist = rel.artists.joinToString(", ") { it.name } + genre = rel.tags.joinToString(", ") { it.name } + } + } + } +} + +@Serializable +class RelationshipsDto( + val authors: List = emptyList(), + val artists: List = emptyList(), + val tags: List = emptyList(), + val cover: CoverDto? = null, +) + +@Serializable +class NamedEntity( + val name: String, +) + +@Serializable +class CoverDto( + val id: String, + val ext: String = ".jpg", +)