Move MangaPlus Creators to new domain (#10337)

* start switching to new URLs for popular/manga/etc

* fix popular

* fix latest

* minor change to manga parse

* refactor popular, fix search

* fix search/popular selector

* fix chapters/pages

* fixes from debugging on android emulator

* increment ext version

* support paginated chapter lists

* break doesn't break

well, that's not true, it did work once the extension was freshly
installed. but I liked the alternative so I thought why not. can
be removed if needed

* cleanup

* add TODOs

* add intents to urls and search prefixes

support both old and new domains (since it all redirects, bless them)

* move around toSManga

pro: we get setUrlWithoutDomain
con: we lose this@<data-class-name>.title

* add filter screen

* debug search

* fix pathPattern

`..*` is the same as `.+`. however, the latter requires adding
`advancedPathPattern` instead

* what the intent: fix classdefnotfoundexception

* categorise into sections

* prefer helper functions from `utils`

* Change inline import to explicit

* inline baseUrl

* inline apiUrl

* remove superfluous header modifications

* always pass headers on new requests

* no need to convert HttpUrl to String

* make helper functions private

* use selectFirst instead of select, assert non-null

* make sub classes defined under filters private

* lint

* prefer not data but class

* Revert "break doesn't break"

This reverts commit 23b2cfe46c0f57214443e138a06cadbef0cccb61.

* lint

* better chapterNumber fail case ( -1f instead of 1f )

* lint
This commit is contained in:
nicki 2025-09-30 00:58:48 -07:00 committed by Draff
parent 0ec1a28ed7
commit 08b627c8e7
Signed by: Draff
GPG Key ID: E8A89F3211677653
5 changed files with 419 additions and 162 deletions

View File

@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application>
<activity
android:name=".all.mangapluscreators.MPCUrlActivity"
android:excludeFromRecents="true"
android:exported="true"
android:theme="@android:style/Theme.NoDisplay">
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="mangaplus-creators.jp"
android:pathAdvancedPattern="/episodes/.+"
android:scheme="https" />
<data
android:host="mangaplus-creators.jp"
android:pathPattern="/titles/..*"
android:scheme="https" />
<data
android:host="mangaplus-creators.jp"
android:pathAdvancedPattern="/authors/.+"
android:scheme="https" />
<data
android:host="medibang.com"
android:pathAdvancedPattern="/mpc/episodes/.+"
android:scheme="https" />
<data
android:host="medibang.com"
android:pathAdvancedPattern="/mpc/titles/..+"
android:scheme="https" />
<data
android:host="medibang.com"
android:pathAdvancedPattern="/mpc/authors/[0-9]+"
android:scheme="https" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@ -1,7 +1,7 @@
ext { ext {
extName = 'MANGA Plus Creators by SHUEISHA' extName = 'MANGA Plus Creators by SHUEISHA'
extClass = '.MangaPlusCreatorsFactory' extClass = '.MangaPlusCreatorsFactory'
extVersionCode = 1 extVersionCode = 2
} }
apply from: "$rootDir/common.gradle" apply from: "$rootDir/common.gradle"

View File

@ -0,0 +1,63 @@
package eu.kanade.tachiyomi.extension.all.mangapluscreators
import android.app.Activity
import android.content.ActivityNotFoundException
import android.content.Intent
import android.os.Bundle
import android.util.Log
import kotlin.system.exitProcess
class MPCUrlActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val pathSegments = intent?.data?.pathSegments
if (pathSegments != null && pathSegments.size > 1) {
// {medibang.com/mpc,mangaplus-creators.jp}/{episodes,titles,authors}
// TODO: val pathIndex = if (intent?.data?.host?.startsWith("medibang") == true) 1 else 0
val host = intent?.data?.host ?: ""
val pathIndex = with(host) {
when {
equals("medibang.com") -> 1
else -> 0
}
}
val idIndex = pathIndex + 1
val query = when {
pathSegments[pathIndex].equals("episodes") -> {
MangaPlusCreators.PREFIX_EPISODE_ID_SEARCH + pathSegments[idIndex]
}
pathSegments[pathIndex].equals("authors") -> {
MangaPlusCreators.PREFIX_AUTHOR_ID_SEARCH + pathSegments[idIndex]
}
pathSegments[pathIndex].equals("titles") -> {
MangaPlusCreators.PREFIX_TITLE_ID_SEARCH + pathSegments[idIndex]
}
else -> null // TODO: is this required?
}
if (query != null) {
// TODO: val mainIntent = Intent().setAction("eu.kanade.tachiyomi.SEARCH").apply {
val mainIntent = Intent().apply {
setAction("eu.kanade.tachiyomi.SEARCH")
putExtra("query", query)
putExtra("filter", packageName)
}
try {
startActivity(mainIntent)
} catch (e: ActivityNotFoundException) {
Log.e("MPCUrlActivity", e.toString())
}
} else {
Log.e("MPCUrlActivity", "Missing alphanumeric ID from the URL")
}
} else {
Log.e("MPCUrlActivity", "Could not parse URI from intent $intent")
}
finish()
exitProcess(0)
}
}

View File

@ -1,6 +1,8 @@
package eu.kanade.tachiyomi.extension.all.mangapluscreators package eu.kanade.tachiyomi.extension.all.mangapluscreators
import eu.kanade.tachiyomi.network.GET import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.asObservableSuccess
import eu.kanade.tachiyomi.source.model.Filter
import eu.kanade.tachiyomi.source.model.FilterList import eu.kanade.tachiyomi.source.model.FilterList
import eu.kanade.tachiyomi.source.model.MangasPage import eu.kanade.tachiyomi.source.model.MangasPage
import eu.kanade.tachiyomi.source.model.Page import eu.kanade.tachiyomi.source.model.Page
@ -8,102 +10,199 @@ import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.HttpSource import eu.kanade.tachiyomi.source.online.HttpSource
import eu.kanade.tachiyomi.util.asJsoup import eu.kanade.tachiyomi.util.asJsoup
import kotlinx.serialization.decodeFromString import keiyoushi.utils.parseAs
import kotlinx.serialization.json.Json import keiyoushi.utils.tryParse
import okhttp3.Headers import okhttp3.Headers
import okhttp3.HttpUrl.Companion.toHttpUrl import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.Request import okhttp3.Request
import okhttp3.Response import okhttp3.Response
import org.jsoup.nodes.Element
import rx.Observable import rx.Observable
import uy.kohesive.injekt.injectLazy import java.text.SimpleDateFormat
import java.util.Locale
class MangaPlusCreators(override val lang: String) : HttpSource() { class MangaPlusCreators(override val lang: String) : HttpSource() {
override val name = "MANGA Plus Creators by SHUEISHA" override val name = "MANGA Plus Creators by SHUEISHA"
override val baseUrl = "https://medibang.com/mpc" override val baseUrl = "https://mangaplus-creators.jp"
private val apiUrl = "$baseUrl/api"
override val supportsLatest = true override val supportsLatest = true
override fun headersBuilder(): Headers.Builder = Headers.Builder() override fun headersBuilder(): Headers.Builder = Headers.Builder()
.add("Origin", baseUrl.substringBeforeLast("/"))
.add("Referer", baseUrl) .add("Referer", baseUrl)
.add("User-Agent", USER_AGENT) .add("User-Agent", USER_AGENT)
private val json: Json by injectLazy() // POPULAR Section
override fun popularMangaRequest(page: Int): Request { override fun popularMangaRequest(page: Int): Request {
val newHeaders = headersBuilder() val popularUrl = "$baseUrl/titles/popular/?p=m&l=$lang".toHttpUrl()
.set("Referer", "$baseUrl/titles/popular/?p=m") return GET(popularUrl, headers)
.add("X-Requested-With", "XMLHttpRequest")
.build()
val apiUrl = "$API_URL/titles/popular/list".toHttpUrl().newBuilder()
.addQueryParameter("page", page.toString())
.addQueryParameter("pageSize", POPULAR_PAGE_SIZE)
.addQueryParameter("l", lang)
.addQueryParameter("p", "m")
.addQueryParameter("isWebview", "false")
.addQueryParameter("_", System.currentTimeMillis().toString())
.toString()
return GET(apiUrl, newHeaders)
} }
override fun popularMangaParse(response: Response): MangasPage { override fun popularMangaParse(response: Response): MangasPage = parseMangasPageFromElement(
val result = response.asMpcResponse() response,
"div.item-recent",
)
checkNotNull(result.titles) { EMPTY_RESPONSE_ERROR } private fun parseMangasPageFromElement(response: Response, selector: String): MangasPage {
val result = response.asJsoup()
val titles = result.titles.titleList.orEmpty().map(MpcTitle::toSManga) val mangas = result.select(selector).map { element ->
popularElementToSManga(element)
}
return MangasPage(titles, result.titles.pagination?.hasNextPage ?: false) return MangasPage(mangas, false)
} }
private fun popularElementToSManga(element: Element): SManga {
val titleThumbnailUrl = element.selectFirst(".image-area img")!!.attr("src")
val titleContentId = titleThumbnailUrl.toHttpUrl().pathSegments[2]
return SManga.create().apply {
title = element.selectFirst(".title-area .title")!!.text()
thumbnail_url = titleThumbnailUrl
setUrlWithoutDomain("/titles/$titleContentId")
}
}
// LATEST Section
override fun latestUpdatesRequest(page: Int): Request { override fun latestUpdatesRequest(page: Int): Request {
val newHeaders = headersBuilder() val apiUrl = "$apiUrl/titles/recent/".toHttpUrl().newBuilder()
.set("Referer", "$baseUrl/titles/recent/?t=episode") .addQueryParameter("page", page.toString())
.add("X-Requested-With", "XMLHttpRequest") .addQueryParameter("l", lang)
.addQueryParameter("t", "episode")
.build() .build()
val apiUrl = "$API_URL/titles/recent/list".toHttpUrl().newBuilder() return GET(apiUrl, headers)
.addQueryParameter("page", page.toString())
.addQueryParameter("pageSize", POPULAR_PAGE_SIZE)
.addQueryParameter("l", lang)
.addQueryParameter("c", "episode")
.addQueryParameter("isWebview", "false")
.addQueryParameter("_", System.currentTimeMillis().toString())
.toString()
return GET(apiUrl, newHeaders)
} }
override fun latestUpdatesParse(response: Response): MangasPage = popularMangaParse(response) override fun latestUpdatesParse(response: Response): MangasPage {
val result = response.parseAs<MpcResponse>()
val titles = result.titles.orEmpty().map { title -> title.toSManga() }
// TODO: handle last page of latest
return MangasPage(titles, result.status != "error")
}
private fun MpcTitle.toSManga(): SManga {
val mTitle = this.title
val mAuthor = this.author.name // TODO: maybe not required
return SManga.create().apply {
title = mTitle
thumbnail_url = thumbnail
setUrlWithoutDomain("/titles/${latestEpisode.titleConnectId}")
author = mAuthor
}
}
// SEARCH Section
override fun fetchSearchManga(page: Int, query: String, filters: FilterList): Observable<MangasPage> {
// TODO: HTTPSource::fetchSearchManga is deprecated? super.getSearchManga
if (query.startsWith(PREFIX_TITLE_ID_SEARCH)) {
val titleContentId = query.removePrefix(PREFIX_TITLE_ID_SEARCH)
val titleUrl = "$baseUrl/titles/$titleContentId"
return client.newCall(GET(titleUrl, headers))
.asObservableSuccess()
.map { response ->
val result = response.asJsoup()
val bookBox = result.selectFirst(".book-box")!!
val title = SManga.create().apply {
title = bookBox.selectFirst("div.title")!!.text()
thumbnail_url = bookBox.selectFirst("div.cover img")!!.attr("data-src")
setUrlWithoutDomain(titleUrl)
}
MangasPage(listOf(title), false)
}
}
if (query.startsWith(PREFIX_EPISODE_ID_SEARCH)) {
val episodeId = query.removePrefix(PREFIX_EPISODE_ID_SEARCH)
return client.newCall(GET("$baseUrl/episodes/$episodeId", headers))
.asObservableSuccess().map { response ->
val result = response.asJsoup()
val readerElement = result.selectFirst("div[react=viewer]")!!
val dataTitle = readerElement.attr("data-title")
val dataTitleResult = dataTitle.parseAs<MpcReaderDataTitle>()
val episodeAsSManga = dataTitleResult.toSManga()
MangasPage(listOf(episodeAsSManga), false)
}
}
if (query.startsWith(PREFIX_AUTHOR_ID_SEARCH)) {
val authorId = query.removePrefix(PREFIX_AUTHOR_ID_SEARCH)
return client.newCall(GET("$baseUrl/authors/$authorId", headers))
.asObservableSuccess()
.map { response ->
val result = response.asJsoup()
val elements = result.select("#works .manga-list li .md\\:block")
val smangas = elements.map { element ->
val titleThumbnailUrl = element.selectFirst(".image-area img")!!.attr("src")
val titleContentId = titleThumbnailUrl.toHttpUrl().pathSegments[2]
SManga.create().apply {
title = element.selectFirst("p.text-white")!!.text().toString()
thumbnail_url = titleThumbnailUrl
setUrlWithoutDomain("/titles/$titleContentId")
}
}
MangasPage(smangas, false)
}
}
if (query.isNotBlank()) {
return super.fetchSearchManga(page, query, filters)
}
// nothing to search, filters active -> browsing /genres instead
// TODO: check if there's a better way (filters is independent of search but part of it)
val genreUrl = baseUrl.toHttpUrl().newBuilder()
.apply {
addPathSegment("genres")
addQueryParameter("l", lang)
filters.forEach { filter ->
when (filter) {
is SortFilter -> {
if (filter.selected.isNotEmpty()) {
addQueryParameter("s", filter.selected)
}
}
is GenreFilter -> addPathSegment(filter.selected)
else -> { /* Nothing else is supported for now */ }
}
}
}.build()
return client.newCall(GET(genreUrl, headers))
.asObservableSuccess()
.map { response ->
popularMangaParse(response)
}
}
private fun MpcReaderDataTitle.toSManga(): SManga {
val mTitle = title
return SManga.create().apply {
title = mTitle
thumbnail_url = thumbnail
setUrlWithoutDomain("/titles/$contentsId")
}
}
override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request { override fun searchMangaRequest(page: Int, query: String, filters: FilterList): Request {
val refererUrl = "$baseUrl/keywords".toHttpUrl().newBuilder() // TODO: maybe this needn't be a new builder and just similar to `popularUrl` above?
val searchUrl = "$baseUrl/keywords".toHttpUrl().newBuilder()
.addQueryParameter("q", query) .addQueryParameter("q", query)
.toString() .addQueryParameter("s", "date")
.addQueryParameter("lang", lang)
val newHeaders = headersBuilder()
.set("Referer", refererUrl)
.add("X-Requested-With", "XMLHttpRequest")
.build() .build()
val apiUrl = "$API_URL/search/titles".toHttpUrl().newBuilder() return GET(searchUrl, headers)
.addQueryParameter("keyword", query)
.addQueryParameter("page", page.toString())
.addQueryParameter("pageSize", POPULAR_PAGE_SIZE)
.addQueryParameter("sort", "newly")
.addQueryParameter("lang", lang)
.addQueryParameter("_", System.currentTimeMillis().toString())
.toString()
return GET(apiUrl, newHeaders)
} }
override fun searchMangaParse(response: Response): MangasPage = popularMangaParse(response) override fun searchMangaParse(response: Response): MangasPage = parseMangasPageFromElement(
response,
"div.item-search",
)
// MANGA Section
override fun mangaDetailsParse(response: Response): SManga { override fun mangaDetailsParse(response: Response): SManga {
val result = response.asJsoup() val result = response.asJsoup()
val bookBox = result.selectFirst(".book-box")!! val bookBox = result.selectFirst(".book-box")!!
@ -119,62 +218,82 @@ class MangaPlusCreators(override val lang: String) : HttpSource() {
else -> SManga.UNKNOWN else -> SManga.UNKNOWN
} }
genre = bookBox.select("div.genre-area div.tag-genre") genre = bookBox.select("div.genre-area div.tag-genre")
.joinToString { it.text() } .joinToString(", ") { it.text() }
thumbnail_url = bookBox.selectFirst("div.cover img")!!.attr("data-src") thumbnail_url = bookBox.selectFirst("div.cover img")!!.attr("data-src")
} }
} }
// CHAPTER Section
override fun chapterListRequest(manga: SManga): Request { override fun chapterListRequest(manga: SManga): Request {
val titleId = manga.url.substringAfterLast("/") val titleContentId = (baseUrl + manga.url).toHttpUrl().pathSegments[1]
return chapterListPageRequest(1, titleContentId)
}
val newHeaders = headersBuilder() private fun chapterListPageRequest(page: Int, titleContentId: String): Request {
.set("Referer", baseUrl + manga.url) return GET("$baseUrl/titles/$titleContentId/?page=$page", headers)
.add("X-Requested-With", "XMLHttpRequest")
.build()
val apiUrl = "$API_URL/titles/$titleId/episodes/".toHttpUrl().newBuilder()
.addQueryParameter("page", "1")
.addQueryParameter("pageSize", CHAPTER_PAGE_SIZE)
.addQueryParameter("_", System.currentTimeMillis().toString())
.toString()
return GET(apiUrl, newHeaders)
} }
override fun chapterListParse(response: Response): List<SChapter> { override fun chapterListParse(response: Response): List<SChapter> {
val result = response.asMpcResponse() val chapterListResponse = chapterListPageParse(response)
val chapterListResult = chapterListResponse.chapters.toMutableList()
checkNotNull(result.episodes) { EMPTY_RESPONSE_ERROR } var hasNextPage = chapterListResponse.hasNextPage
val titleContentId = response.request.url.pathSegments[1]
var page = 1
while (hasNextPage) {
page += 1
val nextPageRequest = chapterListPageRequest(page, titleContentId)
val nextPageResponse = client.newCall(nextPageRequest).execute()
val nextPageResult = chapterListPageParse(nextPageResponse)
if (nextPageResult.chapters.isEmpty()) {
break
}
chapterListResult.addAll(nextPageResult.chapters)
hasNextPage = nextPageResult.hasNextPage
}
return result.episodes.episodeList.orEmpty() return chapterListResult.asReversed()
.sortedByDescending(MpcEpisode::numbering)
.map(MpcEpisode::toSChapter)
} }
override fun pageListRequest(chapter: SChapter): Request { private fun chapterListPageParse(response: Response): ChaptersPage {
val chapterId = chapter.url.substringAfterLast("/") val result = response.asJsoup()
val chapters = result.select(".mod-item-series").map {
val newHeaders = headersBuilder() element ->
.set("Referer", baseUrl + chapter.url) chapterElementToSChapter(element)
.add("X-Requested-With", "XMLHttpRequest") }
.build() val hasResult = result.select(".mod-pagination .next").isNotEmpty()
return ChaptersPage(
val apiUrl = "$API_URL/episodes/pageList/$chapterId/".toHttpUrl().newBuilder() chapters,
.addQueryParameter("_", System.currentTimeMillis().toString()) hasResult,
.toString() )
return GET(apiUrl, newHeaders)
} }
private fun chapterElementToSChapter(element: Element): SChapter {
val episode = element.attr("href").substringAfterLast("/")
val latestUpdatedDate = element.selectFirst(".first-update")!!.text()
val chapterNumberElement = element.selectFirst(".number")!!.text()
val chapterNumber = chapterNumberElement.substringAfter("#").toFloatOrNull()
return SChapter.create().apply {
setUrlWithoutDomain("/episodes/$episode")
date_upload = CHAPTER_DATE_FORMAT.tryParse(latestUpdatedDate)
name = chapterNumberElement
chapter_number = if (chapterNumberElement == "One-shot") {
0F
} else {
chapterNumber ?: -1F
}
}
}
// PAGES & IMAGES Section
override fun pageListParse(response: Response): List<Page> { override fun pageListParse(response: Response): List<Page> {
val result = response.asMpcResponse() val result = response.asJsoup()
val readerElement = result.selectFirst("div[react=viewer]")!!
checkNotNull(result.pageList) { EMPTY_RESPONSE_ERROR } val dataPages = readerElement.attr("data-pages")
val refererUrl = response.request.url.toString()
val referer = response.request.header("Referer")!! return dataPages.parseAs<MpcReaderDataPages>().pc.map {
page ->
return result.pageList.mapIndexed { i, page -> Page(page.pageNo, refererUrl, page.imageUrl)
Page(i, referer, page.publicBgImage)
} }
} }
@ -191,18 +310,66 @@ class MangaPlusCreators(override val lang: String) : HttpSource() {
return GET(page.imageUrl!!, newHeaders) return GET(page.imageUrl!!, newHeaders)
} }
private fun Response.asMpcResponse(): MpcResponse = use {
json.decodeFromString(body.string())
}
companion object { companion object {
private const val API_URL = "https://medibang.com/api/mpc" private val CHAPTER_DATE_FORMAT by lazy {
SimpleDateFormat("yyyy-MM-dd", Locale.ENGLISH)
}
private const val USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " + private const val USER_AGENT = "Mozilla/5.0 (Windows NT 10.0; Win64; x64) " +
"AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.0.0 Safari/537.36" "AppleWebKit/537.36 (KHTML, like Gecko) Chrome/104.0.0.0 Safari/537.36"
const val PREFIX_TITLE_ID_SEARCH = "title:"
private const val POPULAR_PAGE_SIZE = "30" const val PREFIX_EPISODE_ID_SEARCH = "episode:"
private const val CHAPTER_PAGE_SIZE = "200" const val PREFIX_AUTHOR_ID_SEARCH = "author:"
private const val EMPTY_RESPONSE_ERROR = "Empty response from the API. Try again later."
} }
// FILTERS Section
override fun getFilterList() = FilterList(
Filter.Separator(),
Filter.Header("NOTE: Ignored if using text search!"),
Filter.Separator(),
SortFilter(),
GenreFilter(),
Filter.Separator(),
)
private class SortFilter() : SelectFilter(
"Sort",
listOf(
SelectFilterOption("Popularity", ""),
SelectFilterOption("Date", "latest_desc"),
SelectFilterOption("Likes", "like_desc"),
),
0,
)
private class GenreFilter() : SelectFilter(
"Genres",
listOf(
SelectFilterOption("Fantasy", "fantasy"),
SelectFilterOption("Action", "action"),
SelectFilterOption("Romance", "romance"),
SelectFilterOption("Horror", "horror"),
SelectFilterOption("Slice of Life", "slice_of_life"),
SelectFilterOption("Comedy", "comedy"),
SelectFilterOption("Sports", "sports"),
SelectFilterOption("Sci-Fi", "sf"),
SelectFilterOption("Mystery", "mystery"),
SelectFilterOption("Others", "others"),
),
0,
)
private abstract class SelectFilter(
name: String,
private val options: List<SelectFilterOption>,
default: Int = 0,
) : Filter.Select<String>(
name,
options.map { it.name }.toTypedArray(),
default,
) {
val selected: String
get() = options[state].value
}
private class SelectFilterOption(val name: String, val value: String)
} }

View File

@ -1,68 +1,51 @@
package eu.kanade.tachiyomi.extension.all.mangapluscreators package eu.kanade.tachiyomi.extension.all.mangapluscreators
import eu.kanade.tachiyomi.source.model.SChapter import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import kotlinx.serialization.SerialName import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@Serializable @Serializable
data class MpcResponse( class MpcResponse(
@SerialName("mpcEpisodesDto") val episodes: MpcEpisodesDto? = null, val status: String,
@SerialName("mpcTitlesDto") val titles: MpcTitlesDto? = null, val titles: List<MpcTitle>? = null,
val pageList: List<MpcPage>? = emptyList(),
) )
@Serializable @Serializable
data class MpcEpisodesDto( class MpcTitle(
val pagination: MpcPagination? = null,
val episodeList: List<MpcEpisode>? = emptyList(),
)
@Serializable
data class MpcTitlesDto(
val pagination: MpcPagination? = null,
val titleList: List<MpcTitle>? = emptyList(),
)
@Serializable
data class MpcPagination(
val page: Int,
val maxPage: Int,
) {
val hasNextPage: Boolean
get() = page < maxPage
}
@Serializable
data class MpcTitle(
@SerialName("titleId") val id: String,
val title: String, val title: String,
val thumbnailUrl: String, val thumbnail: String,
) { @SerialName("is_one_shot") val isOneShot: Boolean,
val author: MpcAuthorDto,
fun toSManga(): SManga = SManga.create().apply { @SerialName("latest_episode") val latestEpisode: MpcLatestEpisode,
title = this@MpcTitle.title )
thumbnail_url = thumbnailUrl
url = "/titles/$id"
}
}
@Serializable @Serializable
data class MpcEpisode( class MpcAuthorDto(
@SerialName("episodeId") val id: String, val name: String,
@SerialName("episodeTitle") val title: String, )
val numbering: Int,
val oneshot: Boolean = false,
val publishDate: Long,
) {
fun toSChapter(): SChapter = SChapter.create().apply {
name = if (oneshot) "One-shot" else title
date_upload = publishDate
url = "/episodes/$id"
}
}
@Serializable @Serializable
data class MpcPage(val publicBgImage: String) class MpcLatestEpisode(
@SerialName("title_connect_id") val titleConnectId: String,
)
@Serializable
class MpcReaderDataPages(
val pc: List<MpcReaderPage>,
)
@Serializable
class MpcReaderPage(
@SerialName("page_no") val pageNo: Int,
@SerialName("image_url") val imageUrl: String,
)
@Serializable
class MpcReaderDataTitle(
val title: String,
val thumbnail: String,
@SerialName("is_oneshot") val isOneShot: Boolean,
@SerialName("contents_id") val contentsId: String,
)
class ChaptersPage(val chapters: List<SChapter>, val hasNextPage: Boolean)