Add Manga Cross (#11936)
* Add Manga Cross * Manga Cross: show chapter end date in scanlator field * Manga Cross: set next date to 10 am JST
This commit is contained in:
parent
6e8fe89f53
commit
bdeafa7883
|
@ -0,0 +1,2 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest package="eu.kanade.tachiyomi.extension" />
|
|
@ -0,0 +1,12 @@
|
|||
apply plugin: 'com.android.application'
|
||||
apply plugin: 'kotlin-android'
|
||||
apply plugin: 'kotlinx-serialization'
|
||||
|
||||
ext {
|
||||
extName = 'Manga Cross'
|
||||
pkgNameSuffix = 'ja.mangacross'
|
||||
extClass = '.MangaCross'
|
||||
extVersionCode = 1
|
||||
}
|
||||
|
||||
apply from: "$rootDir/common.gradle"
|
Binary file not shown.
After Width: | Height: | Size: 3.2 KiB |
Binary file not shown.
After Width: | Height: | Size: 1.6 KiB |
Binary file not shown.
After Width: | Height: | Size: 4.2 KiB |
Binary file not shown.
After Width: | Height: | Size: 8.9 KiB |
Binary file not shown.
After Width: | Height: | Size: 13 KiB |
Binary file not shown.
After Width: | Height: | Size: 70 KiB |
|
@ -0,0 +1,103 @@
|
|||
package eu.kanade.tachiyomi.extension.ja.mangacross
|
||||
|
||||
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.MangasPage
|
||||
import eu.kanade.tachiyomi.source.model.Page
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import eu.kanade.tachiyomi.source.online.HttpSource
|
||||
import kotlinx.serialization.SerializationException
|
||||
import kotlinx.serialization.decodeFromString
|
||||
import kotlinx.serialization.json.Json
|
||||
import okhttp3.Response
|
||||
import rx.Observable
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
import kotlin.concurrent.thread
|
||||
|
||||
const val maxEntries = 9999
|
||||
|
||||
class MangaCross : HttpSource() {
|
||||
override val name = "Manga Cross"
|
||||
override val lang = "ja"
|
||||
override val baseUrl = "https://mangacross.jp"
|
||||
override val supportsLatest = true
|
||||
|
||||
private val json: Json by injectLazy()
|
||||
|
||||
override fun popularMangaRequest(page: Int) = GET("$baseUrl/api/comics.json?count=$maxEntries", headers)
|
||||
|
||||
override fun popularMangaParse(response: Response) = MangasPage(
|
||||
json.decodeFromString<MCComicList>(response.body!!.string()).comics.map(MCComic::toSManga),
|
||||
false // pagination does not work
|
||||
)
|
||||
|
||||
override fun latestUpdatesRequest(page: Int) = GET("$baseUrl/api/episodes.json?page=$page", headers)
|
||||
|
||||
override fun latestUpdatesParse(response: Response): MangasPage {
|
||||
val result = json.decodeFromString<MCEpisodeList>(response.body!!.string())
|
||||
return MangasPage(result.episodes.map { it.comic!!.toSManga() }, result.current_page < result.total_pages)
|
||||
}
|
||||
|
||||
override fun searchMangaRequest(page: Int, query: String, filters: FilterList) =
|
||||
if (query.isNotEmpty()) {
|
||||
GET("$baseUrl/api/comics/keywords/$query.json", headers)
|
||||
} else when (val tag = filters.filterIsInstance<TagFilter>().firstOrNull()?.getTag()) {
|
||||
null -> popularMangaRequest(page)
|
||||
is MCComicCategory -> GET("$baseUrl/api/comics/categories/${tag.name}.json", headers)
|
||||
is MCComicGenre -> GET("$baseUrl/api/comics/tags/${tag.name}.json", headers)
|
||||
}
|
||||
|
||||
override fun searchMangaParse(response: Response) = popularMangaParse(response)
|
||||
|
||||
override fun fetchMangaDetails(manga: SManga): Observable<SManga> =
|
||||
client.newCall(chapterListRequest(manga))
|
||||
.asObservableSuccess()
|
||||
.map { mangaDetailsParse(it).apply { initialized = true } }
|
||||
|
||||
// mangaDetailsRequest untouched in order to let WebView open web page instead of json
|
||||
|
||||
override fun mangaDetailsParse(response: Response) =
|
||||
json.decodeFromString<MCComicDetails>(response.body!!.string()).comic.toSManga()
|
||||
|
||||
override fun chapterListRequest(manga: SManga) = GET("$baseUrl/api${manga.url}.json", headers)
|
||||
|
||||
override fun chapterListParse(response: Response) =
|
||||
json.decodeFromString<MCComicDetails>(response.body!!.string()).comic.toSChapterList()
|
||||
|
||||
override fun pageListParse(response: Response): List<Page> {
|
||||
return try {
|
||||
json.decodeFromString<MCViewer>(response.body!!.string()).episode_pages.mapIndexed { i, it ->
|
||||
Page(i, "", it.image.original_url)
|
||||
}
|
||||
} catch (e: SerializationException) {
|
||||
throw Exception("Chapter is no longer available!")
|
||||
}
|
||||
}
|
||||
|
||||
override fun imageUrlParse(response: Response) = throw UnsupportedOperationException("Not used.")
|
||||
|
||||
private lateinit var tags: List<Pair<String, MCComicTag?>>
|
||||
|
||||
init {
|
||||
thread {
|
||||
val response = client.newCall(GET("$baseUrl/api/menus.json", headers)).execute()
|
||||
val filterList = json.decodeFromString<MCMenu>(response.body!!.string()).toFilterList()
|
||||
tags = listOf(Pair("None", null)) + filterList
|
||||
}
|
||||
}
|
||||
|
||||
override fun getFilterList() =
|
||||
if (::tags.isInitialized) FilterList(
|
||||
Filter.Header("NOTE: Ignored if using text search!"),
|
||||
TagFilter("Tag", tags)
|
||||
) else FilterList(
|
||||
Filter.Header("Tags not fetched yet. Go back and retry."),
|
||||
)
|
||||
|
||||
private class TagFilter(displayName: String, private val tags: List<Pair<String, MCComicTag?>>) :
|
||||
Filter.Select<String>(displayName, tags.map { it.first }.toTypedArray()) {
|
||||
fun getTag() = tags[state].second
|
||||
}
|
||||
}
|
|
@ -0,0 +1,180 @@
|
|||
package eu.kanade.tachiyomi.extension.ja.mangacross
|
||||
|
||||
import eu.kanade.tachiyomi.source.model.SChapter
|
||||
import eu.kanade.tachiyomi.source.model.SManga
|
||||
import kotlinx.serialization.Serializable
|
||||
import org.jsoup.Jsoup
|
||||
import java.text.DateFormat.getDateTimeInstance
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
|
||||
@Serializable
|
||||
data class MCComicList(
|
||||
val comics: List<MCComic>,
|
||||
// Note: Pagination does not work. Pages after 1 return nothing.
|
||||
// val current_count: Int,
|
||||
// val current_page: Int?,
|
||||
// val total_count: Int,
|
||||
// val total_pages: Int,
|
||||
)
|
||||
|
||||
// Useless fields are omitted while interesting ones are commented
|
||||
@Serializable
|
||||
data class MCComic(
|
||||
val dir_name: String,
|
||||
val title: String,
|
||||
val author: String,
|
||||
val comic_category: MCComicCategory? = null,
|
||||
val comic_tags: List<MCComicGenre>,
|
||||
val image_double_url: String, // horizontal
|
||||
val list_image_double_url: String, // square
|
||||
// val restricted: Boolean, // is NSFW
|
||||
// Details below
|
||||
val outline: String? = null,
|
||||
val episodes: List<MCEpisode>? = null,
|
||||
// val books: List<MCBook>? = null,
|
||||
) {
|
||||
fun toSManga() = SManga.create().apply {
|
||||
url = "/comics/$dir_name"
|
||||
title = this@MCComic.title
|
||||
author = this@MCComic.author
|
||||
description = getDescription()
|
||||
genre = getGenre()
|
||||
status = getStatus()
|
||||
thumbnail_url = list_image_double_url
|
||||
}
|
||||
|
||||
fun toSChapterList() = episodes!!
|
||||
// .filter { it.status == "public" } // preserve private chapters in case user downloaded before
|
||||
.map { it.toSChapter("/comics/$dir_name") }
|
||||
|
||||
private fun getDescription() = listOfNotNull(
|
||||
episodes?.firstOrNull()?.getNextDatePrefix(),
|
||||
outline?.stripHtml()
|
||||
).joinToString("\n")
|
||||
|
||||
private fun getGenre() = listOfNotNull(
|
||||
comic_category?.display_name,
|
||||
comic_tags.joinToString(", ") { it.name }
|
||||
).joinToString(", ")
|
||||
|
||||
private fun getStatus() = when {
|
||||
episodes?.firstOrNull()?.episode_next_date.isNullOrEmpty() -> SManga.UNKNOWN
|
||||
else -> SManga.ONGOING
|
||||
}
|
||||
|
||||
private fun String.stripHtml() = Jsoup.parseBodyFragment(this).text()
|
||||
}
|
||||
|
||||
sealed class MCComicTag
|
||||
|
||||
@Serializable
|
||||
data class MCComicCategory(val name: String, val display_name: String) : MCComicTag()
|
||||
|
||||
@Serializable
|
||||
data class MCComicGenre(val name: String) : MCComicTag()
|
||||
|
||||
@Serializable
|
||||
data class MCComicDetails(val comic: MCComic)
|
||||
|
||||
@Serializable
|
||||
data class MCEpisodeList(
|
||||
val episodes: List<MCEpisode>,
|
||||
// Note: Pagination works.
|
||||
val current_count: Int,
|
||||
val current_page: Int,
|
||||
val total_count: Int,
|
||||
val total_pages: Int,
|
||||
)
|
||||
|
||||
private val jstDate by lazy {
|
||||
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS", Locale.ENGLISH).apply {
|
||||
timeZone = TimeZone.getTimeZone("GMT+09:00")
|
||||
}
|
||||
}
|
||||
|
||||
private val localDate by lazy { getDateTimeInstance() }
|
||||
|
||||
private fun parseJSTDate(date: String) = date.removeSuffix("+09:00").let { jstDate.parse(it) }!!
|
||||
|
||||
@Serializable
|
||||
data class MCEpisode(
|
||||
// val id: Long,
|
||||
val volume: String,
|
||||
val sort_volume: Int,
|
||||
val title: String,
|
||||
val publish_start: String, // all dates are in ISO time format
|
||||
val publish_end: String?,
|
||||
// Note: AFAIK these dates are always identical to those above.
|
||||
// val member_publish_start: String,
|
||||
// val member_publish_end: String?,
|
||||
val status: String, // public or private
|
||||
// val page_url: String,
|
||||
// val list_image_double_url: String,
|
||||
val episode_next_date: String?,
|
||||
val next_date_customize_text: String?,
|
||||
val comic: MCComic? = null, // in latest
|
||||
) {
|
||||
fun toSChapter(urlPrefix: String) = SChapter.create().apply {
|
||||
url = "$urlPrefix/$sort_volume/viewer.json"
|
||||
val prefix = if (status == "public") "" else "🔒 "
|
||||
name = "$prefix$volume $title"
|
||||
// milliseconds are always 000
|
||||
date_upload = parseJSTDate(publish_start).time
|
||||
// show end date in scanlator field
|
||||
scanlator = publish_end?.let { "~" + localDate.format(parseJSTDate(it)) }
|
||||
}
|
||||
|
||||
fun getNextDatePrefix(): String? = when {
|
||||
!episode_next_date.isNullOrEmpty() -> {
|
||||
val date = parseJSTDate(episode_next_date)
|
||||
date.setTime(date.getTime() + 10 * 3600 * 1000) // 10 am JST
|
||||
"【Next: ${localDate.format(date)}】"
|
||||
}
|
||||
!next_date_customize_text.isNullOrEmpty() -> "【$next_date_customize_text】"
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
|
||||
@Serializable
|
||||
data class MCBook(
|
||||
val cover_url: String, // resolution is too low
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MCViewer(
|
||||
// val sort_volume: Int,
|
||||
// val created_at: String,
|
||||
// val updated_at: String,
|
||||
// val volume: String,
|
||||
// val title: String,
|
||||
// val page_count: Int,
|
||||
// episode_viewer_setting: { page_direction: "horizontal" }
|
||||
val episode_pages: List<MCEpisodePage>,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MCEpisodePage(
|
||||
// val order_index: Int,
|
||||
val image: MCImage,
|
||||
// val is_spread_start_page: Boolean,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MCImage(
|
||||
// val pc_url: String, // has highest resolution but is upscaled from original, thus unnecessary
|
||||
// val sp_url: String,
|
||||
// val thumbnail_url: String,
|
||||
val original_url: String,
|
||||
// {pc,sp,thumbnail,original}_geometry: { width: Int, height: Int }
|
||||
)
|
||||
|
||||
@Serializable
|
||||
data class MCMenu(
|
||||
val comic_categories: List<MCComicCategory>,
|
||||
val comic_tags: List<MCComicGenre>,
|
||||
) {
|
||||
fun toFilterList(): List<Pair<String, MCComicTag>> =
|
||||
comic_categories.map { Pair(it.display_name, it) } + comic_tags.map { Pair(it.name, it) }
|
||||
}
|
Loading…
Reference in New Issue