Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 1 addition & 3 deletions src/en/webdexscans/build.gradle
Original file line number Diff line number Diff line change
@@ -1,9 +1,7 @@
ext {
extName = 'Webdex Scans'
extClass = '.WebdexScans'
themePkg = 'madara'
baseUrl = 'https://webdexscans.com'
overrideVersionCode = 1
extVersionCode = 52
isNsfw = false
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
package eu.kanade.tachiyomi.extension.en.webdexscans

import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import keiyoushi.utils.tryParse
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import java.text.SimpleDateFormat

@Serializable
class SearchSeriesDto(
private val title: String,
private val slug: String,
@SerialName("cover_url") private val coverUrl: String? = null,
) {
fun toSManga(baseUrl: String) = SManga.create().apply {
this.title = this@SearchSeriesDto.title
this.url = "/series/$slug"
this.thumbnail_url = coverUrl?.toAbsoluteUrl(baseUrl)
}
}

@Serializable
class SeriesPayload(
val initialSeries: SeriesInfo,
val initialChapters: List<ChapterInfo>? = null,
val initialGenres: List<GenreInfo>? = null,
)

@Serializable
class SeriesInfo(
val slug: String,
private val title: String,
private val description: String? = null,
@SerialName("cover_url") private val coverUrl: String? = null,
private val author: String? = null,
private val artist: String? = null,
private val status: String? = null,
) {
fun toSManga(baseUrl: String, genres: List<GenreInfo>?) = SManga.create().apply {
this.title = this@SeriesInfo.title
this.url = "/series/$slug"
this.thumbnail_url = coverUrl?.toAbsoluteUrl(baseUrl)
this.author = this@SeriesInfo.author
this.artist = this@SeriesInfo.artist
this.description = this@SeriesInfo.description
this.status = when (this@SeriesInfo.status?.lowercase()) {
"ongoing" -> SManga.ONGOING
"completed" -> SManga.COMPLETED
"hiatus" -> SManga.ON_HIATUS
"cancelled" -> SManga.CANCELLED
else -> SManga.UNKNOWN
}
this.genre = genres?.joinToString { it.name }
this.initialized = true
}
}

@Serializable
class ChapterInfo(
private val title: String? = null,
private val slug: String,
@SerialName("chapter_number") private val chapterNumber: Float? = null,
@SerialName("created_at") private val createdAt: String? = null,
@SerialName("is_premium") val isPremium: Boolean = false,
) {
fun toSChapter(seriesSlug: String, dateFormat: SimpleDateFormat) = SChapter.create().apply {
val chapterName = title?.takeIf { it.isNotBlank() }
?: chapterNumber?.toString()?.removeSuffix(".0")?.let { "Chapter $it" }
?: "Chapter"
this.name = if (isPremium) "🔒 $chapterName" else chapterName
this.url = "/series/$seriesSlug/$slug"
// Grab the first 19 characters to format "yyyy-MM-dd'T'HH:mm:ss" properly and bypass timezone + subsecond issues
this.date_upload = dateFormat.tryParse(createdAt?.take(19))
}
}

@Serializable
class GenreInfo(
val name: String,
)

@Serializable
class PagesPayload(
val initialPages: List<PageInfo>,
)

@Serializable
class PageInfo(
@SerialName("image_url") val imageUrl: String,
)

fun String.toAbsoluteUrl(baseUrl: String) = if (this.startsWith("/")) baseUrl + this else this
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
package eu.kanade.tachiyomi.extension.en.webdexscans

import eu.kanade.tachiyomi.source.model.Filter

open class SelectFilter(name: String, private val options: Array<Pair<String, String>>) : Filter.Select<String>(name, options.map { it.first }.toTypedArray()) {
val selected: String?
get() = options[state].second.takeIf { it.isNotEmpty() }
}

class GenreFilter :
SelectFilter(
"Genre",
arrayOf(
"All Genres" to "",
"Action" to "action",
"Adventure" to "adventure",
"Comedy" to "comedy",
"Drama" to "drama",
"Fantasy" to "fantasy",
"Isekai" to "isekai",
"Martial Arts" to "martial-arts",
"Mystery" to "mystery",
"Romance" to "romance",
"Sci-Fi" to "sci-fi",
"Seinen" to "seinen",
"Shounen" to "shounen",
"Slice of Life" to "slice-of-life",
"Supernatural" to "supernatural",
),
)

class TypeFilter :
SelectFilter(
"Type",
arrayOf(
"All Types" to "",
"Manhwa" to "manhwa",
"Manga" to "manga",
"Manhua" to "manhua",
"Webtoon" to "webtoon",
),
)

class StatusFilter :
SelectFilter(
"Status",
arrayOf(
"All Status" to "",
"Ongoing" to "ongoing",
"Completed" to "completed",
"Hiatus" to "hiatus",
),
)

class SortFilter :
SelectFilter(
"Sort By",
arrayOf(
"Latest Update" to "latest",
"Most Popular" to "popular",
"Highest Rating" to "rating",
"Alphabetical" to "a-z",
),
)
Original file line number Diff line number Diff line change
@@ -1,9 +1,196 @@
package eu.kanade.tachiyomi.extension.en.webdexscans

import eu.kanade.tachiyomi.multisrc.madara.Madara
import androidx.preference.CheckBoxPreference
import androidx.preference.PreferenceScreen
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.source.ConfigurableSource
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.extractNextJs
import keiyoushi.utils.firstInstanceOrNull
import keiyoushi.utils.getPreferencesLazy
import keiyoushi.utils.parseAs
import kotlinx.serialization.json.JsonObject
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Request
import okhttp3.Response
import java.text.SimpleDateFormat
import java.util.Locale
import java.util.TimeZone

class WebdexScans : Madara("Webdex Scans", "https://webdexscans.com", "en") {
override val mangaSubString = "series"
override val useNewChapterEndpoint = true
override val mangaDetailsSelectorStatus = "div.summary-heading:contains(Status) + div.summary-content"
class WebdexScans :
HttpSource(),
ConfigurableSource {

override val name = "Webdex Scans"
override val baseUrl = "https://webdexscans.com"
override val lang = "en"
override val supportsLatest = true

// Hardcode versionId to force users to migrate their old Madara entries.
override val versionId = 2

private val preferences by getPreferencesLazy()

private val supabaseUrl = "https://nrqghtbdrdnoywxjkgkf.supabase.co/rest/v1"
private val supabaseApiKey = "eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJpc3MiOiJzdXBhYmFzZSIsInJlZiI6Im5ycWdodGJkcmRub3l3eGprZ2tmIiwicm9sZSI6ImFub24iLCJpYXQiOjE3NzY4Njg4NDEsImV4cCI6MjA5MjQ0NDg0MX0.Gnrn33_LMxFA9m_OdCpybBZ-Cjcc5rdsJlD8Y9eOICg"

private val supabaseHeaders by lazy {
headersBuilder()
.add("apikey", supabaseApiKey)
.add("authorization", "Bearer $supabaseApiKey")
.add("Accept", "application/json")
.build()
}

private val dateFormat = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.ROOT).apply {
timeZone = TimeZone.getTimeZone("UTC")
}

// ============================== Popular ==============================

override fun popularMangaRequest(page: Int): Request {
val offset = (page - 1) * 24
val url = "$supabaseUrl/series".toHttpUrl().newBuilder()
.addQueryParameter("select", "title,slug,cover_url")
.addQueryParameter("order", "view_count.desc")
.addQueryParameter("offset", offset.toString())
.addQueryParameter("limit", "24")
.build()
return GET(url, supabaseHeaders)
}

override fun popularMangaParse(response: Response): MangasPage {
val mangas = response.parseAs<List<SearchSeriesDto>>().map { it.toSManga(baseUrl) }
return MangasPage(mangas, mangas.size == 24)
}

// ============================== Latest ===============================

override fun latestUpdatesRequest(page: Int): Request {
val offset = (page - 1) * 24
val url = "$supabaseUrl/series".toHttpUrl().newBuilder()
.addQueryParameter("select", "title,slug,cover_url")
.addQueryParameter("order", "updated_at.desc")
.addQueryParameter("offset", offset.toString())
.addQueryParameter("limit", "24")
.build()
return GET(url, supabaseHeaders)
}

override fun latestUpdatesParse(response: Response) = popularMangaParse(response)

// ============================== Search ===============================

override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
val offset = (page - 1) * 24
val url = "$supabaseUrl/series".toHttpUrl().newBuilder()

val genreSlug = filters.firstInstanceOrNull<GenreFilter>()?.selected
if (genreSlug != null) {
url.addQueryParameter("select", "title,slug,cover_url,genres!inner(slug)")
url.addQueryParameter("genres.slug", "eq.$genreSlug")
} else {
url.addQueryParameter("select", "title,slug,cover_url")
}

if (query.isNotEmpty()) {
url.addQueryParameter("title", "ilike.%$query%")
}

filters.firstInstanceOrNull<TypeFilter>()?.selected?.let {
url.addQueryParameter("type", "eq.$it")
}

filters.firstInstanceOrNull<StatusFilter>()?.selected?.let {
url.addQueryParameter("status", "eq.$it")
}

when (filters.firstInstanceOrNull<SortFilter>()?.selected) {
"popular" -> url.addQueryParameter("order", "view_count.desc")
"rating" -> url.addQueryParameter("order", "rating.desc")
"a-z" -> url.addQueryParameter("order", "title.asc")
"latest", null -> url.addQueryParameter("order", "updated_at.desc")
}

url.addQueryParameter("offset", offset.toString())
url.addQueryParameter("limit", "24")

return GET(url.build(), supabaseHeaders)
}

override fun searchMangaParse(response: Response) = popularMangaParse(response)

// ============================= Utilities =============================

private fun Response.extractSeriesPayload(): SeriesPayload = extractNextJs<SeriesPayload> {
it is JsonObject && "initialSeries" in it && "initialChapters" in it
} ?: throw Exception("Failed to extract series payload")

// ============================== Details ==============================

override fun mangaDetailsParse(response: Response): SManga {
val payload = response.extractSeriesPayload()
return payload.initialSeries.toSManga(baseUrl, payload.initialGenres)
}

// ============================= Chapters ==============================

override fun chapterListParse(response: Response): List<SChapter> {
val payload = response.extractSeriesPayload()

val seriesSlug = payload.initialSeries.slug
val showPremium = preferences.getBoolean(PREF_SHOW_PREMIUM, false)

val chapters = payload.initialChapters ?: emptyList()
val filteredChapters = if (showPremium) {
chapters
} else {
chapters.filterNot { it.isPremium }
}

return filteredChapters.map { it.toSChapter(seriesSlug, dateFormat) }
}

// =============================== Pages ===============================

override fun pageListParse(response: Response): List<Page> {
val payload = response.extractNextJs<PagesPayload> {
it is JsonObject && "initialPages" in it
} ?: throw Exception("Failed to extract pages payload")

return payload.initialPages.mapIndexed { i, page ->
Page(i, imageUrl = page.imageUrl.toAbsoluteUrl(baseUrl))
}
}

override fun imageUrlParse(response: Response): String = throw UnsupportedOperationException()

// ============================== Filters ==============================

override fun getFilterList() = FilterList(
GenreFilter(),
TypeFilter(),
StatusFilter(),
SortFilter(),
)

// ============================ Preferences ============================

override fun setupPreferenceScreen(screen: PreferenceScreen) {
CheckBoxPreference(screen.context).apply {
key = PREF_SHOW_PREMIUM
title = "Show premium chapters"
summary = "Include chapters that require coins to read"
setDefaultValue(false)
}.also(screen::addPreference)
}

companion object {
private const val PREF_SHOW_PREMIUM = "pref_show_premium"
}
}