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

616 lines
22 KiB
Kotlin

package com.lagradost.cloudstream3.syncproviders.providers
import android.util.Base64
import com.fasterxml.jackson.annotation.JsonProperty
import com.fasterxml.jackson.databind.DeserializationFeature
import com.fasterxml.jackson.databind.json.JsonMapper
import com.fasterxml.jackson.module.kotlin.KotlinModule
import com.fasterxml.jackson.module.kotlin.readValue
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.app
import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.syncproviders.AccountManager
import com.lagradost.cloudstream3.syncproviders.OAuth2API
import com.lagradost.cloudstream3.syncproviders.OAuth2API.Companion.appString
import com.lagradost.cloudstream3.syncproviders.OAuth2API.Companion.secondsToReadable
import com.lagradost.cloudstream3.syncproviders.OAuth2API.Companion.unixTime
import com.lagradost.cloudstream3.syncproviders.SyncAPI
import com.lagradost.cloudstream3.utils.AppUtils.splitQuery
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
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 val name = "MAL"
override val key = "1714d6f2f4f7cc19644384f8c4629910"
override val redirectUrl = "mallogin"
override val idPrefix = "mal"
override val mainUrl = "https://myanimelist.net"
override val icon = R.drawable.mal_logo
override fun logOut() {
removeAccountKeys()
}
override fun loginInfo(): OAuth2API.LoginInfo? {
//getMalUser(true)?
getKey<MalUser>(accountId, MAL_USER_KEY)?.let { user ->
return OAuth2API.LoginInfo(profilePicture = user.picture, name = user.name, accountIndex = accountIndex)
}
return null
}
override suspend fun search(name: String): List<SyncAPI.SyncSearchResult> {
val url = "https://api.myanimelist.net/v2/anime?q=$name&limit=$MAL_MAX_SEARCH_LIMIT"
val auth = getKey<String>(
accountId,
MAL_TOKEN_KEY
) ?: return emptyList()
val res = app.get(
url, headers = mapOf(
"Authorization" to "Bearer " + auth,
), cacheTime = 0
).text
return mapper.readValue<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 suspend fun score(id: String, status : SyncAPI.SyncStatus): Boolean {
return setScoreRequest(
id.toIntOrNull() ?: return false,
fromIntToAnimeStatus(status.status),
status.score,
status.watchedEpisodes
)
}
override suspend fun getResult(id: String): SyncAPI.SyncResult? {
val internalId = id.toIntOrNull() ?: return null
TODO("Not yet implemented")
}
override suspend fun getStatus(id: String): SyncAPI.SyncStatus? {
val internalId = id.toIntOrNull() ?: return null
val data = getDataAboutMalId(internalId)?.my_list_status ?: return null
return SyncAPI.SyncStatus(
score = data.score,
status = 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_SHOULD_UPDATE_LIST: String = "mal_should_update_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
}
override fun handleRedirect(url: String) {
try {
val sanitizer =
splitQuery(URL(url.replace(appString, "https").replace("/#", "?"))) // FIX ERROR
val state = sanitizer["state"]!!
if (state == "RequestID$requestId") {
val currentCode = sanitizer["code"]!!
ioSafe {
var res = ""
try {
//println("cc::::: " + codeVerifier)
res = app.post(
"https://myanimelist.net/v1/oauth2/token",
data = mapOf(
"client_id" to key,
"code" to currentCode,
"code_verifier" to codeVerifier,
"grant_type" to "authorization_code"
)
).text
} catch (e: Exception) {
e.printStackTrace()
}
if (res != "") {
switchToNewAccount()
storeToken(res)
getMalUser()
setKey(MAL_SHOULD_UPDATE_LIST, true)
}
}
}
} catch (e: Exception) {
e.printStackTrace()
}
}
override fun authenticate() {
// 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 =
"https://myanimelist.net/v1/oauth2/authorize?response_type=code&client_id=$key&code_challenge=$codeChallenge&state=RequestID$requestId"
openBrowser(request)
}
private val mapper = JsonMapper.builder().addModule(KotlinModule())
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false).build()!!
private var requestId = 0
private var codeVerifier = ""
private fun storeToken(response: String) {
try {
if (response != "") {
val token = mapper.readValue<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)
}
} catch (e: Exception) {
e.printStackTrace()
}
}
private suspend fun refreshToken() {
try {
val res = app.post(
"https://myanimelist.net/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) {
e.printStackTrace()
}
}
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?,
)
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?
)
fun getMalAnimeListCached(): Array<Data>? {
return getKey(MAL_CACHED_LIST) as? Array<Data>
}
suspend fun getMalAnimeListSmart(): Array<Data>? {
if (getKey<String>(
accountId,
MAL_TOKEN_KEY
) == null
) return null
return if (getKey(MAL_SHOULD_UPDATE_LIST, true) == true) {
val list = getMalAnimeList()
if (list != null) {
setKey(MAL_CACHED_LIST, list)
setKey(MAL_SHOULD_UPDATE_LIST, false)
}
list
} else {
getMalAnimeListCached()
}
}
private suspend fun getMalAnimeList(): Array<Data>? {
return try {
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
}
fullList.toTypedArray()
//mapper.readValue<MalAnime>(res)
} catch (e: Exception) {
null
}
}
fun convertToStatus(string: String): MalStatusType {
return fromIntToAnimeStatus(malStatusAsString.indexOf(string))
}
private suspend fun getMalAnimeListSlice(offset: Int = 0): MalList? {
val user = "@me"
val auth = getKey<String>(
accountId,
MAL_TOKEN_KEY
) ?: return null
return try {
// Very lackluster docs
// https://myanimelist.net/apiconfig/references/api/v2#operation/users_user_id_animelist_get
val url =
"https://api.myanimelist.net/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
res.toKotlinObject()
} catch (e: Exception) {
logError(e)
null
}
}
private suspend fun getDataAboutMalId(id: Int): MalAnime? {
return try {
// https://myanimelist.net/apiconfig/references/api/v2#operation/anime_anime_id_get
val url = "https://api.myanimelist.net/v2/anime/$id?fields=id,title,num_episodes,my_list_status"
val res = app.get(
url, headers = mapOf(
"Authorization" to "Bearer " + getKey<String>(
accountId,
MAL_TOKEN_KEY
)!!
), cacheTime = 0
).text
mapper.readValue<MalAnime>(res)
} catch (e: Exception) {
null
}
}
suspend fun setAllMalData() {
val user = "@me"
var isDone = false
var index = 0
allTitles.clear()
checkMalToken()
while (!isDone) {
val res = app.get(
"https://api.myanimelist.net/v2/users/$user/animelist?fields=list_status&limit=1000&offset=${index * 1000}",
headers = mapOf(
"Authorization" to "Bearer " + getKey<String>(
accountId,
MAL_TOKEN_KEY
)!!
), cacheTime = 0
).text
val values = mapper.readValue<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? {
try {
// 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")
} catch (e: Exception) {
logError(e)
}
return null
}
private suspend fun checkMalToken() {
if (unixTime > getKey(
accountId,
MAL_UNIXTIME_KEY
) ?: 0L
) {
refreshToken()
}
}
private suspend fun getMalUser(setSettings: Boolean = true): MalUser? {
checkMalToken()
return try {
val res = app.get(
"https://api.myanimelist.net/v2/users/@me",
headers = mapOf(
"Authorization" to "Bearer " + getKey<String>(
accountId,
MAL_TOKEN_KEY
)!!
), cacheTime = 0
).text
val user = mapper.readValue<MalUser>(res)
if (setSettings) {
setKey(accountId, MAL_USER_KEY, user)
registerAccount()
}
user
} catch (e: Exception) {
e.printStackTrace()
null
}
}
enum class MalStatusType(var value: Int) {
Watching(0),
Completed(1),
OnHold(2),
Dropped(3),
PlanToWatch(4),
None(-1)
}
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
}
}
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
)
if (res != "") {
return try {
val malStatus = mapper.readValue<MalStatus>(res)
if (allTitles.containsKey(id)) {
val currentTitle = allTitles[id]!!
allTitles[id] = MalTitleHolder(malStatus, id, currentTitle.name)
} else {
allTitles[id] = MalTitleHolder(malStatus, id, "")
}
true
} catch (e: Exception) {
logError(e)
false
}
} else {
return false
}
}
private suspend fun setScoreRequest(
id: Int,
status: String? = null,
score: Int? = null,
num_watched_episodes: Int? = null,
): String {
return try {
app.put(
"https://api.myanimelist.net/v2/anime/$id/my_list_status",
headers = mapOf(
"Authorization" to "Bearer " + getKey<String>(
accountId,
MAL_TOKEN_KEY
)!!
),
data = mapOf(
"status" to status,
"score" to score?.toString(),
"num_watched_episodes" to num_watched_episodes?.toString()
)
).text
} catch (e: Exception) {
e.printStackTrace()
return ""
}
}
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 MalAnime(
@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,
)
}