AquaStream/app/src/main/java/com/lagradost/cloudstream3/syncproviders/providers/MALApi.kt

766 lines
28 KiB
Kotlin

package com.lagradost.cloudstream3.syncproviders.providers
import android.util.Base64
import androidx.annotation.StringRes
import androidx.fragment.app.FragmentActivity
import com.fasterxml.jackson.annotation.JsonProperty
import com.lagradost.cloudstream3.AcraApplication.Companion.getKey
import com.lagradost.cloudstream3.AcraApplication.Companion.openBrowser
import com.lagradost.cloudstream3.AcraApplication.Companion.setKey
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.ShowStatus
import com.lagradost.cloudstream3.TvType
import com.lagradost.cloudstream3.app
import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.syncproviders.AccountManager
import com.lagradost.cloudstream3.syncproviders.AuthAPI
import com.lagradost.cloudstream3.syncproviders.SyncAPI
import com.lagradost.cloudstream3.syncproviders.SyncIdName
import com.lagradost.cloudstream3.ui.SyncWatchType
import com.lagradost.cloudstream3.ui.library.ListSorting
import com.lagradost.cloudstream3.ui.result.txt
import com.lagradost.cloudstream3.utils.AppUtils.parseJson
import com.lagradost.cloudstream3.utils.AppUtils.splitQuery
import com.lagradost.cloudstream3.utils.DataStore.toKotlinObject
import java.net.URL
import java.security.SecureRandom
import java.text.ParseException
import java.text.SimpleDateFormat
import java.util.*
/** max 100 via https://myanimelist.net/apiconfig/references/api/v2#tag/anime */
const val MAL_MAX_SEARCH_LIMIT = 25
class MALApi(index: Int) : AccountManager(index), SyncAPI {
override var name = "MAL"
override val key = "1714d6f2f4f7cc19644384f8c4629910"
override val redirectUrl = "mallogin"
override val idPrefix = "mal"
override var mainUrl = "https://myanimelist.net"
private val apiUrl = "https://api.myanimelist.net"
override val icon = R.drawable.mal_logo
override val requiresLogin = false
override val syncIdName = SyncIdName.MyAnimeList
override var requireLibraryRefresh = true
override val createAccountUrl = "$mainUrl/register.php"
override fun logOut() {
requireLibraryRefresh = true
removeAccountKeys()
}
override fun loginInfo(): AuthAPI.LoginInfo? {
//getMalUser(true)?
getKey<MalUser>(accountId, MAL_USER_KEY)?.let { user ->
return AuthAPI.LoginInfo(
profilePicture = user.picture,
name = user.name,
accountIndex = accountIndex
)
}
return null
}
private fun getAuth(): String? {
return getKey(
accountId,
MAL_TOKEN_KEY
)
}
override suspend fun search(name: String): List<SyncAPI.SyncSearchResult> {
val url = "$apiUrl/v2/anime?q=$name&limit=$MAL_MAX_SEARCH_LIMIT"
val auth = getAuth() ?: return emptyList()
val res = app.get(
url, headers = mapOf(
"Authorization" to "Bearer $auth",
), cacheTime = 0
).text
return parseJson<MalSearch>(res).data.map {
val node = it.node
SyncAPI.SyncSearchResult(
node.title,
this.name,
node.id.toString(),
"$mainUrl/anime/${node.id}/",
node.main_picture?.large ?: node.main_picture?.medium
)
}
}
override fun getIdFromUrl(url: String): String {
return Regex("""/anime/((.*)/|(.*))""").find(url)!!.groupValues.first()
}
override suspend fun score(id: String, status: SyncAPI.AbstractSyncStatus): Boolean {
return setScoreRequest(
id.toIntOrNull() ?: return false,
fromIntToAnimeStatus(status.status.internalId),
status.score,
status.watchedEpisodes
).also {
requireLibraryRefresh = requireLibraryRefresh || it
}
}
data class MalAnime(
@JsonProperty("id") val id: Int?,
@JsonProperty("title") val title: String?,
@JsonProperty("main_picture") val mainPicture: MainPicture?,
@JsonProperty("alternative_titles") val alternativeTitles: AlternativeTitles?,
@JsonProperty("start_date") val startDate: String?,
@JsonProperty("end_date") val endDate: String?,
@JsonProperty("synopsis") val synopsis: String?,
@JsonProperty("mean") val mean: Double?,
@JsonProperty("rank") val rank: Int?,
@JsonProperty("popularity") val popularity: Int?,
@JsonProperty("num_list_users") val numListUsers: Int?,
@JsonProperty("num_scoring_users") val numScoringUsers: Int?,
@JsonProperty("nsfw") val nsfw: String?,
@JsonProperty("created_at") val createdAt: String?,
@JsonProperty("updated_at") val updatedAt: String?,
@JsonProperty("media_type") val mediaType: String?,
@JsonProperty("status") val status: String?,
@JsonProperty("genres") val genres: ArrayList<Genres>?,
@JsonProperty("my_list_status") val myListStatus: MyListStatus?,
@JsonProperty("num_episodes") val numEpisodes: Int?,
@JsonProperty("start_season") val startSeason: StartSeason?,
@JsonProperty("broadcast") val broadcast: Broadcast?,
@JsonProperty("source") val source: String?,
@JsonProperty("average_episode_duration") val averageEpisodeDuration: Int?,
@JsonProperty("rating") val rating: String?,
@JsonProperty("pictures") val pictures: ArrayList<MainPicture>?,
@JsonProperty("background") val background: String?,
@JsonProperty("related_anime") val relatedAnime: ArrayList<RelatedAnime>?,
@JsonProperty("related_manga") val relatedManga: ArrayList<String>?,
@JsonProperty("recommendations") val recommendations: ArrayList<Recommendations>?,
@JsonProperty("studios") val studios: ArrayList<Studios>?,
@JsonProperty("statistics") val statistics: Statistics?,
)
data class Recommendations(
@JsonProperty("node") val node: Node? = null,
@JsonProperty("num_recommendations") val numRecommendations: Int? = null
)
data class Studios(
@JsonProperty("id") val id: Int? = null,
@JsonProperty("name") val name: String? = null
)
data class MyListStatus(
@JsonProperty("status") val status: String? = null,
@JsonProperty("score") val score: Int? = null,
@JsonProperty("num_episodes_watched") val numEpisodesWatched: Int? = null,
@JsonProperty("is_rewatching") val isRewatching: Boolean? = null,
@JsonProperty("updated_at") val updatedAt: String? = null
)
data class RelatedAnime(
@JsonProperty("node") val node: Node? = null,
@JsonProperty("relation_type") val relationType: String? = null,
@JsonProperty("relation_type_formatted") val relationTypeFormatted: String? = null
)
data class Status(
@JsonProperty("watching") val watching: String? = null,
@JsonProperty("completed") val completed: String? = null,
@JsonProperty("on_hold") val onHold: String? = null,
@JsonProperty("dropped") val dropped: String? = null,
@JsonProperty("plan_to_watch") val planToWatch: String? = null
)
data class Statistics(
@JsonProperty("status") val status: Status? = null,
@JsonProperty("num_list_users") val numListUsers: Int? = null
)
private fun parseDate(string: String?): Long? {
return try {
SimpleDateFormat("yyyy-MM-dd")?.parse(string ?: return null)?.time
} catch (e: Exception) {
null
}
}
private fun toSearchResult(node: Node?): SyncAPI.SyncSearchResult? {
return SyncAPI.SyncSearchResult(
name = node?.title ?: return null,
apiName = this.name,
syncId = node.id.toString(),
url = "$mainUrl/anime/${node.id}",
posterUrl = node.main_picture?.large
)
}
override suspend fun getResult(id: String): SyncAPI.SyncResult? {
val internalId = id.toIntOrNull() ?: return null
val url =
"$apiUrl/v2/anime/$internalId?fields=id,title,main_picture,alternative_titles,start_date,end_date,synopsis,mean,rank,popularity,num_list_users,num_scoring_users,nsfw,created_at,updated_at,media_type,status,genres,my_list_status,num_episodes,start_season,broadcast,source,average_episode_duration,rating,pictures,background,related_anime,related_manga,recommendations,studios,statistics"
val auth = getAuth()
val res = app.get(
url, headers = if (auth == null) emptyMap() else mapOf(
"Authorization" to "Bearer $auth"
)
).text
return parseJson<MalAnime>(res).let { malAnime ->
SyncAPI.SyncResult(
id = internalId.toString(),
totalEpisodes = malAnime.numEpisodes,
title = malAnime.title,
publicScore = malAnime.mean?.toFloat()?.times(1000)?.toInt(),
duration = malAnime.averageEpisodeDuration,
synopsis = malAnime.synopsis,
airStatus = when (malAnime.status) {
"finished_airing" -> ShowStatus.Completed
"currently_airing" -> ShowStatus.Ongoing
//"not_yet_aired"
else -> null
},
nextAiring = null,
studio = malAnime.studios?.mapNotNull { it.name },
genres = malAnime.genres?.map { it.name },
trailers = null,
startDate = parseDate(malAnime.startDate),
endDate = parseDate(malAnime.endDate),
recommendations = malAnime.recommendations?.mapNotNull { rec ->
val node = rec.node ?: return@mapNotNull null
toSearchResult(node)
},
nextSeason = malAnime.relatedAnime?.firstOrNull {
return@firstOrNull it.relationType == "sequel"
}?.let { toSearchResult(it.node) },
prevSeason = malAnime.relatedAnime?.firstOrNull {
return@firstOrNull it.relationType == "prequel"
}?.let { toSearchResult(it.node) },
actors = null,
)
}
}
override suspend fun getStatus(id: String): SyncAPI.SyncStatus? {
val internalId = id.toIntOrNull() ?: return null
val data =
getDataAboutMalId(internalId)?.my_list_status //?: throw ErrorLoadingException("No my_list_status")
return SyncAPI.SyncStatus(
score = data?.score,
status = SyncWatchType.fromInternalId(malStatusAsString.indexOf(data?.status)) ,
isFavorite = null,
watchedEpisodes = data?.num_episodes_watched,
)
}
companion object {
private val malStatusAsString =
arrayOf("watching", "completed", "on_hold", "dropped", "plan_to_watch")
const val MAL_USER_KEY: String = "mal_user" // user data like profile
const val MAL_CACHED_LIST: String = "mal_cached_list"
const val MAL_UNIXTIME_KEY: String = "mal_unixtime" // When token expires
const val MAL_REFRESH_TOKEN_KEY: String = "mal_refresh_token" // refresh token
const val MAL_TOKEN_KEY: String = "mal_token" // anilist token for api
fun convertToStatus(string: String): MalStatusType {
return fromIntToAnimeStatus(malStatusAsString.indexOf(string))
}
enum class MalStatusType(var value: Int, @StringRes val stringRes: Int) {
Watching(0, R.string.type_watching),
Completed(1, R.string.type_completed),
OnHold(2, R.string.type_on_hold),
Dropped(3, R.string.type_dropped),
PlanToWatch(4, R.string.type_plan_to_watch),
None(-1, R.string.type_none)
}
private fun fromIntToAnimeStatus(inp: Int): MalStatusType {//= AniListStatusType.values().first { it.value == inp }
return when (inp) {
-1 -> MalStatusType.None
0 -> MalStatusType.Watching
1 -> MalStatusType.Completed
2 -> MalStatusType.OnHold
3 -> MalStatusType.Dropped
4 -> MalStatusType.PlanToWatch
5 -> MalStatusType.Watching
else -> MalStatusType.None
}
}
private fun parseDateLong(string: String?): Long? {
return try {
SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssZ").parse(
string ?: return null
)?.time?.div(1000)
} catch (e: Exception) {
null
}
}
}
override suspend fun handleRedirect(url: String): Boolean {
val sanitizer =
splitQuery(URL(url.replace(appString, "https").replace("/#", "?"))) // FIX ERROR
val state = sanitizer["state"]!!
if (state == "RequestID$requestId") {
val currentCode = sanitizer["code"]!!
val res = app.post(
"$mainUrl/v1/oauth2/token",
data = mapOf(
"client_id" to key,
"code" to currentCode,
"code_verifier" to codeVerifier,
"grant_type" to "authorization_code"
)
).text
if (res.isNotBlank()) {
switchToNewAccount()
storeToken(res)
val user = getMalUser()
requireLibraryRefresh = true
return user != null
}
}
return false
}
override fun authenticate(activity: FragmentActivity?) {
// It is recommended to use a URL-safe string as code_verifier.
// See section 4 of RFC 7636 for more details.
val secureRandom = SecureRandom()
val codeVerifierBytes = ByteArray(96) // base64 has 6bit per char; (8/6)*96 = 128
secureRandom.nextBytes(codeVerifierBytes)
codeVerifier =
Base64.encodeToString(codeVerifierBytes, Base64.DEFAULT).trimEnd('=').replace("+", "-")
.replace("/", "_").replace("\n", "")
val codeChallenge = codeVerifier
val request =
"$mainUrl/v1/oauth2/authorize?response_type=code&client_id=$key&code_challenge=$codeChallenge&state=RequestID$requestId"
openBrowser(request, activity)
}
private var requestId = 0
private var codeVerifier = ""
private fun storeToken(response: String) {
try {
if (response != "") {
val token = parseJson<ResponseToken>(response)
setKey(accountId, MAL_UNIXTIME_KEY, (token.expires_in + unixTime))
setKey(accountId, MAL_REFRESH_TOKEN_KEY, token.refresh_token)
setKey(accountId, MAL_TOKEN_KEY, token.access_token)
requireLibraryRefresh = true
}
} catch (e: Exception) {
logError(e)
}
}
private suspend fun refreshToken() {
try {
val res = app.post(
"$mainUrl/v1/oauth2/token",
data = mapOf(
"client_id" to key,
"grant_type" to "refresh_token",
"refresh_token" to getKey(
accountId,
MAL_REFRESH_TOKEN_KEY
)!!
)
).text
storeToken(res)
} catch (e: Exception) {
logError(e)
}
}
private val allTitles = hashMapOf<Int, MalTitleHolder>()
data class MalList(
@JsonProperty("data") val data: List<Data>,
@JsonProperty("paging") val paging: Paging
)
data class MainPicture(
@JsonProperty("medium") val medium: String,
@JsonProperty("large") val large: String
)
data class Node(
@JsonProperty("id") val id: Int,
@JsonProperty("title") val title: String,
@JsonProperty("main_picture") val main_picture: MainPicture?,
@JsonProperty("alternative_titles") val alternative_titles: AlternativeTitles?,
@JsonProperty("media_type") val media_type: String?,
@JsonProperty("num_episodes") val num_episodes: Int?,
@JsonProperty("status") val status: String?,
@JsonProperty("start_date") val start_date: String?,
@JsonProperty("end_date") val end_date: String?,
@JsonProperty("average_episode_duration") val average_episode_duration: Int?,
@JsonProperty("synopsis") val synopsis: String?,
@JsonProperty("mean") val mean: Double?,
@JsonProperty("genres") val genres: List<Genres>?,
@JsonProperty("rank") val rank: Int?,
@JsonProperty("popularity") val popularity: Int?,
@JsonProperty("num_list_users") val num_list_users: Int?,
@JsonProperty("num_favorites") val num_favorites: Int?,
@JsonProperty("num_scoring_users") val num_scoring_users: Int?,
@JsonProperty("start_season") val start_season: StartSeason?,
@JsonProperty("broadcast") val broadcast: Broadcast?,
@JsonProperty("nsfw") val nsfw: String?,
@JsonProperty("created_at") val created_at: String?,
@JsonProperty("updated_at") val updated_at: String?
)
data class ListStatus(
@JsonProperty("status") val status: String?,
@JsonProperty("score") val score: Int,
@JsonProperty("num_episodes_watched") val num_episodes_watched: Int,
@JsonProperty("is_rewatching") val is_rewatching: Boolean,
@JsonProperty("updated_at") val updated_at: String,
)
data class Data(
@JsonProperty("node") val node: Node,
@JsonProperty("list_status") val list_status: ListStatus?,
) {
fun toLibraryItem(): SyncAPI.LibraryItem {
return SyncAPI.LibraryItem(
this.node.title,
"https://myanimelist.net/anime/${this.node.id}/",
this.node.id.toString(),
this.list_status?.num_episodes_watched,
this.node.num_episodes,
this.list_status?.score?.times(10),
parseDateLong(this.list_status?.updated_at),
"MAL",
TvType.Anime,
this.node.main_picture?.large ?: this.node.main_picture?.medium,
null,
null,
plot = this.node.synopsis,
)
}
}
data class Paging(
@JsonProperty("next") val next: String?
)
data class AlternativeTitles(
@JsonProperty("synonyms") val synonyms: List<String>,
@JsonProperty("en") val en: String,
@JsonProperty("ja") val ja: String
)
data class Genres(
@JsonProperty("id") val id: Int,
@JsonProperty("name") val name: String
)
data class StartSeason(
@JsonProperty("year") val year: Int,
@JsonProperty("season") val season: String
)
data class Broadcast(
@JsonProperty("day_of_the_week") val day_of_the_week: String?,
@JsonProperty("start_time") val start_time: String?
)
private fun getMalAnimeListCached(): Array<Data>? {
return getKey(MAL_CACHED_LIST) as? Array<Data>
}
private suspend fun getMalAnimeListSmart(): Array<Data>? {
if (getAuth() == null) return null
return if (requireLibraryRefresh) {
val list = getMalAnimeList()
setKey(MAL_CACHED_LIST, list)
list
} else {
getMalAnimeListCached()
}
}
override suspend fun getPersonalLibrary(): SyncAPI.LibraryMetadata {
val list = getMalAnimeListSmart()?.groupBy {
convertToStatus(it.list_status?.status ?: "").stringRes
}?.mapValues { group ->
group.value.map { it.toLibraryItem() }
} ?: emptyMap()
// To fill empty lists when MAL does not return them
val baseMap =
MalStatusType.values().filter { it.value >= 0 }.associate {
it.stringRes to emptyList<SyncAPI.LibraryItem>()
}
return SyncAPI.LibraryMetadata(
(baseMap + list).map { SyncAPI.LibraryList(txt(it.key), it.value) },
setOf(
ListSorting.AlphabeticalA,
ListSorting.AlphabeticalZ,
ListSorting.UpdatedNew,
ListSorting.UpdatedOld,
ListSorting.RatingHigh,
ListSorting.RatingLow,
)
)
}
private suspend fun getMalAnimeList(): Array<Data> {
checkMalToken()
var offset = 0
val fullList = mutableListOf<Data>()
val offsetRegex = Regex("""offset=(\d+)""")
while (true) {
val data: MalList = getMalAnimeListSlice(offset) ?: break
fullList.addAll(data.data)
offset =
data.paging.next?.let { offsetRegex.find(it)?.groupValues?.get(1)?.toInt() }
?: break
}
return fullList.toTypedArray()
}
private suspend fun getMalAnimeListSlice(offset: Int = 0): MalList? {
val user = "@me"
val auth = getAuth() ?: return null
// Very lackluster docs
// https://myanimelist.net/apiconfig/references/api/v2#operation/users_user_id_animelist_get
val url =
"$apiUrl/v2/users/$user/animelist?fields=list_status,num_episodes,media_type,status,start_date,end_date,synopsis,alternative_titles,mean,genres,rank,num_list_users,nsfw,average_episode_duration,num_favorites,popularity,num_scoring_users,start_season,favorites_info,broadcast,created_at,updated_at&nsfw=1&limit=100&offset=$offset"
val res = app.get(
url, headers = mapOf(
"Authorization" to "Bearer $auth",
), cacheTime = 0
).text
return res.toKotlinObject()
}
private suspend fun getDataAboutMalId(id: Int): SmallMalAnime? {
// https://myanimelist.net/apiconfig/references/api/v2#operation/anime_anime_id_get
val url =
"$apiUrl/v2/anime/$id?fields=id,title,num_episodes,my_list_status"
val res = app.get(
url, headers = mapOf(
"Authorization" to "Bearer " + (getAuth() ?: return null)
), cacheTime = 0
).text
return parseJson<SmallMalAnime>(res)
}
suspend fun setAllMalData() {
val user = "@me"
var isDone = false
var index = 0
allTitles.clear()
checkMalToken()
while (!isDone) {
val res = app.get(
"$apiUrl/v2/users/$user/animelist?fields=list_status&limit=1000&offset=${index * 1000}",
headers = mapOf(
"Authorization" to "Bearer " + (getAuth() ?: return)
), cacheTime = 0
).text
val values = parseJson<MalRoot>(res)
val titles =
values.data.map { MalTitleHolder(it.list_status, it.node.id, it.node.title) }
for (t in titles) {
allTitles[t.id] = t
}
isDone = titles.size < 1000
index++
}
}
fun convertJapanTimeToTimeRemaining(date: String, endDate: String? = null): String? {
// No time remaining if the show has already ended
try {
endDate?.let {
if (SimpleDateFormat("yyyy-MM-dd").parse(it).time < System.currentTimeMillis()) return@convertJapanTimeToTimeRemaining null
}
} catch (e: ParseException) {
logError(e)
}
// Unparseable date: "2021 7 4 other null"
// Weekday: other, date: null
if (date.contains("null") || date.contains("other")) {
return null
}
val currentDate = Calendar.getInstance()
val currentMonth = currentDate.get(Calendar.MONTH) + 1
val currentWeek = currentDate.get(Calendar.WEEK_OF_MONTH)
val currentYear = currentDate.get(Calendar.YEAR)
val dateFormat = SimpleDateFormat("yyyy MM W EEEE HH:mm")
dateFormat.timeZone = TimeZone.getTimeZone("Japan")
val parsedDate =
dateFormat.parse("$currentYear $currentMonth $currentWeek $date") ?: return null
val timeDiff = (parsedDate.time - System.currentTimeMillis()) / 1000
// if it has already aired this week add a week to the timer
val updatedTimeDiff =
if (timeDiff > -60 * 60 * 24 * 7 && timeDiff < 0) timeDiff + 60 * 60 * 24 * 7 else timeDiff
return secondsToReadable(updatedTimeDiff.toInt(), "Now")
}
private suspend fun checkMalToken() {
if (unixTime > (getKey(
accountId,
MAL_UNIXTIME_KEY
) ?: 0L)
) {
refreshToken()
}
}
private suspend fun getMalUser(setSettings: Boolean = true): MalUser? {
checkMalToken()
val res = app.get(
"$apiUrl/v2/users/@me",
headers = mapOf(
"Authorization" to "Bearer " + (getAuth() ?: return null)
), cacheTime = 0
).text
val user = parseJson<MalUser>(res)
if (setSettings) {
setKey(accountId, MAL_USER_KEY, user)
registerAccount()
}
return user
}
private suspend fun setScoreRequest(
id: Int,
status: MalStatusType? = null,
score: Int? = null,
num_watched_episodes: Int? = null,
): Boolean {
val res = setScoreRequest(
id,
if (status == null) null else malStatusAsString[maxOf(0, status.value)],
score,
num_watched_episodes
)
return if (res.isNullOrBlank()) {
false
} else {
val malStatus = parseJson<MalStatus>(res)
if (allTitles.containsKey(id)) {
val currentTitle = allTitles[id]!!
allTitles[id] = MalTitleHolder(malStatus, id, currentTitle.name)
} else {
allTitles[id] = MalTitleHolder(malStatus, id, "")
}
true
}
}
private suspend fun setScoreRequest(
id: Int,
status: String? = null,
score: Int? = null,
num_watched_episodes: Int? = null,
): String? {
val data = mapOf(
"status" to status,
"score" to score?.toString(),
"num_watched_episodes" to num_watched_episodes?.toString()
).filter { it.value != null } as Map<String, String>
return app.put(
"$apiUrl/v2/anime/$id/my_list_status",
headers = mapOf(
"Authorization" to "Bearer " + (getAuth() ?: return null)
),
data = data
).text
}
data class ResponseToken(
@JsonProperty("token_type") val token_type: String,
@JsonProperty("expires_in") val expires_in: Int,
@JsonProperty("access_token") val access_token: String,
@JsonProperty("refresh_token") val refresh_token: String,
)
data class MalRoot(
@JsonProperty("data") val data: List<MalDatum>,
)
data class MalDatum(
@JsonProperty("node") val node: MalNode,
@JsonProperty("list_status") val list_status: MalStatus,
)
data class MalNode(
@JsonProperty("id") val id: Int,
@JsonProperty("title") val title: String,
/*
also, but not used
main_picture ->
public string medium;
public string large;
*/
)
data class MalStatus(
@JsonProperty("status") val status: String,
@JsonProperty("score") val score: Int,
@JsonProperty("num_episodes_watched") val num_episodes_watched: Int,
@JsonProperty("is_rewatching") val is_rewatching: Boolean,
@JsonProperty("updated_at") val updated_at: String,
)
data class MalUser(
@JsonProperty("id") val id: Int,
@JsonProperty("name") val name: String,
@JsonProperty("location") val location: String,
@JsonProperty("joined_at") val joined_at: String,
@JsonProperty("picture") val picture: String?,
)
data class MalMainPicture(
@JsonProperty("large") val large: String?,
@JsonProperty("medium") val medium: String?,
)
// Used for getDataAboutId()
data class SmallMalAnime(
@JsonProperty("id") val id: Int,
@JsonProperty("title") val title: String?,
@JsonProperty("num_episodes") val num_episodes: Int,
@JsonProperty("my_list_status") val my_list_status: MalStatus?,
@JsonProperty("main_picture") val main_picture: MalMainPicture?,
)
data class MalSearchNode(
@JsonProperty("node") val node: Node,
)
data class MalSearch(
@JsonProperty("data") val data: List<MalSearchNode>,
//paging
)
data class MalTitleHolder(
val status: MalStatus,
val id: Int,
val name: String,
)
}