MangAdventure API v2 (#9344)

This commit is contained in:
ObserverOfTime 2021-10-04 14:30:20 +03:00 committed by GitHub
parent 8be6df8f3b
commit 62537462ac
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 271 additions and 302 deletions

View File

@ -3,10 +3,8 @@ package eu.kanade.tachiyomi.extension.en.arcrelight
import eu.kanade.tachiyomi.multisrc.mangadventure.MangAdventure
/** Arc-Relight source. */
class ArcRelight : MangAdventure(
"Arc-Relight",
"https://arc-relight.com",
categories = listOf(
class ArcRelight : MangAdventure("Arc-Relight", "https://arc-relight.com") {
override val categories = listOf(
"4-Koma",
"Chaos;Head",
"Collection",
@ -24,4 +22,4 @@ class ArcRelight : MangAdventure(
"Supernatural",
"Tragedy"
)
)
}

View File

@ -5,197 +5,187 @@ import android.os.Build.VERSION
import eu.kanade.tachiyomi.BuildConfig
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.Page as SPage
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.online.HttpSource
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.JsonPrimitive
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import okhttp3.Headers
import okhttp3.Request
import kotlinx.serialization.json.decodeFromJsonElement
import okhttp3.Response
import rx.Observable
import uy.kohesive.injekt.injectLazy
import java.text.SimpleDateFormat
import java.util.Locale
/**
* MangAdventure base source.
*
* @property categories the available manga categories of the site.
*/
/** MangAdventure base source. */
abstract class MangAdventure(
override val name: String,
override val baseUrl: String,
override val lang: String = "en",
val categories: List<String> = DEFAULT_CATEGORIES
override val lang: String = "en"
) : HttpSource() {
/** The site's manga categories. */
protected open val categories = DEFAULT_CATEGORIES
override val versionId = 1
/** The site's manga status names. */
protected open val statuses = arrayOf("Any", "Completed", "Ongoing")
override val supportsLatest = true
/** The site's sort order labels that correspond to [SortOrder.values]. */
protected open val orders = arrayOf("Title", "Latest upload", "Chapter count")
/** The full URL to the site's API. */
open val apiUrl by lazy { "$baseUrl/api/v$versionId" }
/**
* A user agent representing Tachiyomi.
* Includes the user's Android version
* and the current extension version.
*/
/** A user agent representing Tachiyomi. */
private val userAgent = "Mozilla/5.0 " +
"(Android ${VERSION.RELEASE}; Mobile) " +
"Tachiyomi/${BuildConfig.VERSION_NAME}"
private val json: Json by injectLazy()
/** The URI of the site's API. */
private val apiUri by lazy { Uri.parse("$baseUrl/api/v2")!! }
override fun headersBuilder() = Headers.Builder().apply {
add("User-Agent", userAgent)
add("Referer", baseUrl)
}
/** The JSON parser of the class. */
private val json by injectLazy<Json>()
override val supportsLatest = true
override fun headersBuilder() =
super.headersBuilder().set("User-Agent", userAgent)
override fun latestUpdatesRequest(page: Int) =
GET("$apiUrl/releases/", headers)
apiUri.buildUpon().appendEncodedPath("series").run {
appendQueryParameter("page", page.toString())
appendQueryParameter("sort", "-latest_upload")
GET(toString(), headers)
}
override fun pageListRequest(chapter: SChapter) =
GET("$apiUrl/series/${chapter.path}", headers)
override fun popularMangaRequest(page: Int) =
apiUri.buildUpon().appendEncodedPath("series").run {
appendQueryParameter("page", page.toString())
appendQueryParameter("sort", "-chapter_count")
GET(toString(), headers)
}
override fun searchMangaRequest(page: Int, query: String, filters: FilterList) =
apiUri.buildUpon().appendEncodedPath("series").run {
if (query.startsWith(SLUG_QUERY)) {
appendQueryParameter("slug", query.substring(SLUG_QUERY.length))
} else {
appendQueryParameter("page", page.toString())
appendQueryParameter("title", query)
filters.forEach {
when (it) {
is Author -> appendQueryParameter("author", it.toString())
is Artist -> appendQueryParameter("artist", it.toString())
is Status -> appendQueryParameter("status", it.toString())
is SortOrder -> appendQueryParameter("sort", it.toString())
is CategoryList -> appendQueryParameter("categories", it.toString())
else -> Unit
}
}
}
GET(toString(), headers)
}
override fun chapterListRequest(manga: SManga) =
GET("$apiUrl/series/${manga.slug}/", headers)
// Workaround to allow "Open in browser" to use the real URL
override fun fetchMangaDetails(manga: SManga): Observable<SManga> =
client.newCall(chapterListRequest(manga)).asObservableSuccess()
.map { mangaDetailsParse(it).apply { initialized = true } }
// Return the real URL for "Open in browser"
override fun mangaDetailsRequest(manga: SManga) = GET(manga.url, headers)
override fun searchMangaRequest(
page: Int,
query: String,
filters: FilterList
): Request {
val uri = Uri.parse("$apiUrl/series/").buildUpon()
if (query.startsWith(SLUG_QUERY)) {
uri.appendQueryParameter("slug", query.substringAfter(SLUG_QUERY))
return GET(uri.toString(), headers)
apiUri.buildUpon().appendEncodedPath("chapters").run {
appendQueryParameter("series", manga.slug)
appendQueryParameter("date_format", "timestamp")
GET(toString(), headers)
}
uri.appendQueryParameter("q", query)
val cat = mutableListOf<String>()
filters.forEach {
when (it) {
is Person -> uri.appendQueryParameter("author", it.state)
is Status -> uri.appendQueryParameter("status", it.string())
is CategoryList -> cat.addAll(
it.state.mapNotNull { c ->
Uri.encode(c.optString())
}
)
else -> Unit
}
override fun pageListRequest(chapter: SChapter) =
apiUri.buildUpon().appendEncodedPath("pages").run {
val (slug, vol, num) = chapter.components
appendQueryParameter("series", slug)
appendQueryParameter("volume", vol)
appendQueryParameter("number", num)
GET(toString(), headers)
}
return GET("$uri&categories=${cat.joinToString(",")}", headers)
}
override fun latestUpdatesParse(response: Response) =
json.parseToJsonElement(response.body!!.string()).run {
MangasPage(
jsonArray.map {
val obj = it.jsonObject
SManga.create().apply {
url = obj["url"]!!.jsonPrimitive.content
title = obj["title"]!!.jsonPrimitive.content
thumbnail_url = obj["cover"]!!.jsonPrimitive.content
// A bit of a hack to sort by date
val latest = obj["latest_chapter"]!!.jsonObject
description = httpDateToTimestamp(
latest["date"]!!.jsonPrimitive.content
).toString()
}
}.sortedByDescending(SManga::description),
false
)
response.decode<Paginator<Series>>().let {
MangasPage(it.map(::mangaFromJSON), !it.last)
}
override fun searchMangaParse(response: Response) =
latestUpdatesParse(response)
override fun popularMangaParse(response: Response) =
latestUpdatesParse(response)
override fun chapterListParse(response: Response) =
json.parseToJsonElement(response.body!!.string())
.jsonObject["volumes"]!!.jsonObject.entries.flatMap { vol ->
vol.value.jsonObject.entries.map { ch ->
SChapter.create().fromJSON(
JsonObject(
ch.value.jsonObject.toMutableMap().also {
it["volume"] = JsonPrimitive(vol.key)
it["chapter"] = JsonPrimitive(ch.key)
}
)
)
response.decode<Results<Chapter>>().map { chapter ->
SChapter.create().apply {
url = chapter.url
name = buildString {
append(chapter.full_title)
if (chapter.final) append(" [END]")
}
chapter_number = chapter.number
date_upload = chapter.published.toLong()
scanlator = chapter.groups.joinToString()
}
}
override fun mangaDetailsParse(response: Response) =
SManga.create().fromJSON(
json.parseToJsonElement(response.body!!.string()).jsonObject
)
response.decode<Series>().let(::mangaFromJSON)
override fun pageListParse(response: Response) =
json.parseToJsonElement(response.body!!.string()).jsonObject.run {
val url = get("url")!!.jsonPrimitive.content
val root = get("pages_root")!!.jsonPrimitive.content
get("pages_list")!!.jsonArray.mapIndexed { i, e ->
Page(i, "$url${i + 1}", "$root${e.jsonPrimitive.content}")
response.decode<Results<Page>>().map { page ->
SPage(page.number, page.url, page.image)
}
// Return the real URL for "Open in browser"
override fun mangaDetailsRequest(manga: SManga) =
GET(baseUrl + manga.url, headers)
// Workaround to allow "Open in browser" to use the real URL
override fun fetchMangaDetails(manga: SManga) =
client.newCall(GET("$apiUri/series/${manga.slug}", headers))
.asObservableSuccess().map {
mangaDetailsParse(it).apply { initialized = true }
}!!
override fun imageUrlParse(response: Response) =
throw UnsupportedOperationException("Not used!")
override fun getFilterList() =
FilterList(
Author(),
Artist(),
SortOrder(orders),
Status(statuses),
CategoryList(categories)
)
/** The slug of the manga. */
private inline val SManga.slug
get() = url.split('/')[2]
/** The components (series, volume, number) of the chapter. */
private inline val SChapter.components
get() = url.split('/').slice(2..5)
/** Decodes the JSON response as an object. */
private inline fun <reified T> Response.decode() =
json.decodeFromJsonElement<T>(json.parseToJsonElement(body!!.string()))
/** Converts a [Series] object to an [SManga]. */
private fun mangaFromJSON(series: Series) =
SManga.create().apply {
url = series.url
title = series.title
thumbnail_url = series.cover
description = series.description
author = series.authors?.joinToString()
artist = series.artists?.joinToString()
genre = series.categories?.joinToString()
status = when (series.completed) {
true -> SManga.COMPLETED
false -> SManga.ONGOING
null -> SManga.UNKNOWN
}
}
override fun searchMangaParse(response: Response) =
json.parseToJsonElement(response.body!!.string()).run {
MangasPage(
jsonArray.map {
val obj = it.jsonObject
SManga.create().apply {
url = obj["url"]!!.jsonPrimitive.content
title = obj["title"]!!.jsonPrimitive.content
thumbnail_url = obj["cover"]!!.jsonPrimitive.content
}
}.sortedBy(SManga::title),
false
)
}
override fun getFilterList() =
FilterList(Person(), Status(), CategoryList())
override fun fetchPopularManga(page: Int) =
fetchSearchManga(page, "", FilterList())
override fun popularMangaRequest(page: Int) =
throw UnsupportedOperationException(
"This method should not be called!"
)
override fun popularMangaParse(response: Response) =
throw UnsupportedOperationException(
"This method should not be called!"
)
override fun imageUrlParse(response: Response) =
throw UnsupportedOperationException(
"This method should not be called!"
)
companion object {
/** The possible statuses of a manga. */
private val STATUSES = arrayOf("Any", "Completed", "Ongoing")
/** Manga categories from MangAdventure `categories.xml` fixture. */
internal val DEFAULT_CATEGORIES = listOf(
val DEFAULT_CATEGORIES = listOf(
"4-Koma",
"Action",
"Adventure",
@ -233,61 +223,5 @@ abstract class MangAdventure(
/** Query to search by manga slug. */
internal const val SLUG_QUERY = "slug:"
/**
* The HTTP date format specified in
* [RFC 1123](https://tools.ietf.org/html/rfc1123#page-55).
*/
private const val HTTP_DATE = "EEE, dd MMM yyyy HH:mm:ss zzz"
/**
* Converts a date in the [HTTP_DATE] format to a Unix timestamp.
*
* @param date The date to convert.
* @return The timestamp of the date.
*/
internal fun httpDateToTimestamp(date: String) =
SimpleDateFormat(HTTP_DATE, Locale.US).parse(date)?.time ?: 0L
}
/**
* Filter representing the status of a manga.
*
* @constructor Creates a [Filter.Select] object with [STATUSES].
*/
inner class Status : Filter.Select<String>("Status", STATUSES) {
/** Returns the [state] as a string. */
fun string() = values[state].toLowerCase(Locale(lang))
}
/**
* Filter representing a manga category.
*
* @property name The display name of the category.
* @constructor Creates a [Filter.TriState] object using [name].
*/
inner class Category(name: String) : Filter.TriState(name) {
/** Returns the [state] as a string, or null if [isIgnored]. */
fun optString() = when (state) {
STATE_INCLUDE -> name.toLowerCase(Locale(lang))
STATE_EXCLUDE -> "-" + name.toLowerCase(Locale(lang))
else -> null
}
}
/**
* Filter representing the [categories][Category] of a manga.
*
* @constructor Creates a [Filter.Group] object with categories.
*/
inner class CategoryList : Filter.Group<Category>(
"Categories", categories.map(::Category)
)
/**
* Filter representing the name of an author or artist.
*
* @constructor Creates a [Filter.Text] object.
*/
inner class Person : Filter.Text("Author/Artist")
}

View File

@ -0,0 +1,67 @@
package eu.kanade.tachiyomi.multisrc.mangadventure
/** Generic results wrapper schema. */
@kotlinx.serialization.Serializable
internal class Results<T>(
private val results: List<T>
) : Iterable<T> by results
/** Generic paginator schema. */
@kotlinx.serialization.Serializable
internal class Paginator<T>(
val last: Boolean,
private val results: List<T>
) : Iterable<T> by results
/** Page model schema. */
@kotlinx.serialization.Serializable
internal data class Page(
private val id: Int,
val image: String,
val number: Int,
val url: String
) {
override fun equals(other: Any?) =
this === other || other is Page && id == other.id
override fun hashCode() = id
}
/** Chapter model schema. */
@kotlinx.serialization.Serializable
internal data class Chapter(
private val id: Int,
val title: String,
val number: Float,
val volume: Int,
val published: String,
val final: Boolean,
val series: String,
val groups: List<String>,
val full_title: String,
val url: String
) {
override fun equals(other: Any?) =
this === other || other is Chapter && id == other.id
override fun hashCode() = id
}
/** Series model schema. */
@kotlinx.serialization.Serializable
internal data class Series(
private val slug: String,
val title: String,
val url: String,
val cover: String?,
val description: String? = null,
val completed: Boolean? = null,
val authors: List<String>? = null,
val artists: List<String>? = null,
val categories: List<String>? = null
) {
override fun equals(other: Any?) =
this === other || other is Series && slug == other.slug
override fun hashCode() = slug.hashCode()
}

View File

@ -1,93 +0,0 @@
package eu.kanade.tachiyomi.multisrc.mangadventure
import android.net.Uri
import eu.kanade.tachiyomi.source.model.SChapter
import eu.kanade.tachiyomi.source.model.SManga
import kotlinx.serialization.json.JsonArray
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.boolean
import kotlinx.serialization.json.contentOrNull
import kotlinx.serialization.json.intOrNull
import kotlinx.serialization.json.jsonArray
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import java.text.DecimalFormat
/**
* Formats the number according to [fmt].
*
* @param fmt A [DecimalFormat] string.
* @return A string representation of the number.
*/
fun Number.format(fmt: String): String = DecimalFormat(fmt).format(this)
/**
* Joins each value of a given [field] of the array using [sep].
*
* @param field The index of a [JsonArray].
* @param sep The separator used to join the array.
* @return The joined string, or `null` if the array is empty.
*/
fun JsonArray.joinField(field: Int, sep: String = ", ") =
size.takeIf { it != 0 }?.run {
joinToString(sep) { it.jsonArray[field].jsonPrimitive.content }
}
/**
* Joins each value of a given [field] of the array using [sep].
*
* @param field The key of a [JsonObject].
* @param sep The separator used to join the array.
* @return The joined string, or `null` if the array is empty.
*/
fun JsonArray.joinField(field: String, sep: String = ", ") =
size.takeIf { it != 0 }?.run {
joinToString(sep) { it.jsonObject[field]!!.jsonPrimitive.content }
}
/** The slug of a manga. */
val SManga.slug: String
get() = Uri.parse(url).lastPathSegment!!
/**
* Creates a [SManga] by parsing a [JsonObject].
*
* @param obj The object containing the manga info.
*/
fun SManga.fromJSON(obj: JsonObject) = apply {
url = obj["url"]!!.jsonPrimitive.content
title = obj["title"]!!.jsonPrimitive.content
description = obj["description"]!!.jsonPrimitive.content
thumbnail_url = obj["cover"]!!.jsonPrimitive.content
author = obj["authors"]!!.jsonArray.joinField(0)
artist = obj["artists"]!!.jsonArray.joinField(0)
genre = obj["categories"]!!.jsonArray.joinField("name")
status = if (obj["completed"]!!.jsonPrimitive.boolean)
SManga.COMPLETED else SManga.ONGOING
}
/** The unique path of a chapter. */
val SChapter.path: String
get() = url.substringAfter("/reader/")
/**
* Creates a [SChapter] by parsing a [JsonObject].
*
* @param obj The object containing the chapter info.
*/
fun SChapter.fromJSON(obj: JsonObject) = apply {
url = obj["url"]!!.jsonPrimitive.content
chapter_number = obj["chapter"]?.jsonPrimitive?.content?.toFloat() ?: -1f
date_upload = MangAdventure.httpDateToTimestamp(
obj["date"]!!.jsonPrimitive.content
)
scanlator = obj["groups"]!!.jsonArray.joinField("name", " & ")
name = obj["full_title"]?.jsonPrimitive?.contentOrNull ?: buildString {
obj["volume"]?.jsonPrimitive?.intOrNull?.let {
if (it != 0) append("Vol. $it, ")
}
append("Ch. ${chapter_number.format("#.#")}: ")
append(obj["title"]!!.jsonPrimitive.content)
}
if (obj["final"]!!.jsonPrimitive.boolean) name += " [END]"
}

View File

@ -0,0 +1,63 @@
package eu.kanade.tachiyomi.multisrc.mangadventure
import eu.kanade.tachiyomi.source.model.Filter
/** Filter representing the name of an author. */
internal class Author : Filter.Text("Author") {
override fun toString() = state
}
/** Filter representing the name of an artist. */
internal class Artist : Filter.Text("Artist") {
override fun toString() = state
}
/**
* Filter representing the sort order.
*
* @param labels The site's sort order labels.
*/
internal class SortOrder(
private val labels: Array<String>
) : Filter.Sort("Sort", values, null) {
override fun toString() = when (state?.ascending) {
null -> ""
true -> labels[state!!.index]
false -> "-" + labels[state!!.index]
}
companion object {
/** The available sort order values. */
private val values = arrayOf("title", "latest_upload", "chapter_count")
}
}
/**
* Filter representing the status of a manga.
*
* @param statuses The site's status names.
*/
internal class Status(
statuses: Array<String>
) : Filter.Select<String>("Status", statuses) {
override fun toString() = values[state]
}
/**
* Filter representing a manga category.
*
* @param name The display name of the category.
*/
internal class Category(name: String) : Filter.TriState(name)
/**
* Filter representing the [categories][Category] of a manga.
*
* @param categories The site's manga categories.
*/
internal class CategoryList(
categories: List<String>
) : Filter.Group<Category>("Categories", categories.map(::Category)) {
override fun toString() = state.filterNot { it.isIgnored() }
.joinToString(",") { if (it.isIncluded()) it.name else "-" + it.name }
}

View File

@ -9,7 +9,7 @@ class MangAdventureGenerator : ThemeSourceGenerator {
override val themeClass = "MangAdventure"
override val baseVersionCode = 4
override val baseVersionCode = 5
override val sources = listOf(
SingleLang("Arc-Relight", "https://arc-relight.com", "en", className = "ArcRelight"),