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