forked from recloudstream/cloudstream
anilist/mal api, (NO UI YET)
This commit is contained in:
parent
d323092f11
commit
d6870836d9
17 changed files with 1845 additions and 48 deletions
|
@ -63,6 +63,12 @@
|
|||
<data android:scheme="https" android:host="vidembed.cc" android:pathPrefix="/"/>
|
||||
<data android:scheme="https" android:host="trailers.to" android:pathPrefix="/"/>
|
||||
</intent-filter>
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.VIEW"/>
|
||||
<category android:name="android.intent.category.DEFAULT"/>
|
||||
<category android:name="android.intent.category.BROWSABLE"/>
|
||||
<data android:scheme="cloudstreamapp"/>
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<receiver
|
||||
|
|
|
@ -28,6 +28,8 @@ import com.lagradost.cloudstream3.APIHolder.restrictedApis
|
|||
import com.lagradost.cloudstream3.mvvm.logError
|
||||
import com.lagradost.cloudstream3.network.initRequestClient
|
||||
import com.lagradost.cloudstream3.receivers.VideoDownloadRestartReceiver
|
||||
import com.lagradost.cloudstream3.syncproviders.OAuth2Interface.Companion.OAuth2Apis
|
||||
import com.lagradost.cloudstream3.syncproviders.OAuth2Interface.Companion.appString
|
||||
import com.lagradost.cloudstream3.ui.APIRepository
|
||||
import com.lagradost.cloudstream3.ui.download.DOWNLOAD_NAVIGATE_TO
|
||||
import com.lagradost.cloudstream3.ui.player.PlayerEventType
|
||||
|
@ -347,6 +349,13 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
|
|||
if (intent == null) return
|
||||
val str = intent.dataString
|
||||
if (str != null) {
|
||||
if (str.contains(appString)) {
|
||||
for (api in OAuth2Apis) {
|
||||
if (str.contains("/${api.redirectUrl}")) {
|
||||
api.handleRedirect(this, str)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
if (str.startsWith(DOWNLOAD_NAVIGATE_TO)) {
|
||||
this.navigate(R.id.navigation_downloads)
|
||||
} else {
|
||||
|
@ -359,6 +368,7 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
val settingsManager = PreferenceManager.getDefaultSharedPreferences(this)
|
||||
|
|
|
@ -65,10 +65,12 @@ val Response.cookies: Map<String, String>
|
|||
}?.filter { it.key.isNotBlank() && it.value.isNotBlank() } ?: mapOf()
|
||||
}
|
||||
|
||||
fun getData(data: Map<String, String>): RequestBody {
|
||||
fun getData(data: Map<String, String?>): RequestBody {
|
||||
val builder = FormBody.Builder()
|
||||
data.forEach {
|
||||
builder.add(it.key, it.value)
|
||||
it.value?.let { value ->
|
||||
builder.add(it.key, value)
|
||||
}
|
||||
}
|
||||
return builder.build()
|
||||
}
|
||||
|
@ -83,10 +85,12 @@ fun appendUri(uri: String, appendQuery: String): String {
|
|||
}
|
||||
|
||||
// Can probably be done recursively
|
||||
fun addParamsToUrl(url: String, params: Map<String, String>): String {
|
||||
fun addParamsToUrl(url: String, params: Map<String, String?>): String {
|
||||
var appendedUrl = url
|
||||
params.forEach {
|
||||
appendedUrl = appendUri(appendedUrl, "${it.key}=${it.value}")
|
||||
it.value?.let { value ->
|
||||
appendedUrl = appendUri(appendedUrl, "${it.key}=${value}")
|
||||
}
|
||||
}
|
||||
return appendedUrl
|
||||
}
|
||||
|
@ -189,3 +193,44 @@ fun postRequestCreator(
|
|||
.post(getData(data))
|
||||
.build()
|
||||
}
|
||||
|
||||
fun putRequestCreator(
|
||||
url: String,
|
||||
headers: Map<String, String>,
|
||||
referer: String?,
|
||||
params: Map<String, String?>,
|
||||
cookies: Map<String, String>,
|
||||
data: Map<String, String?>,
|
||||
cacheTime: Int,
|
||||
cacheUnit: TimeUnit
|
||||
): Request {
|
||||
return Request.Builder()
|
||||
.url(addParamsToUrl(url, params))
|
||||
.cacheControl(getCache(cacheTime, cacheUnit))
|
||||
.headers(getHeaders(headers, referer, cookies))
|
||||
.put(getData(data))
|
||||
.build()
|
||||
}
|
||||
|
||||
|
||||
fun put(
|
||||
url: String,
|
||||
headers: Map<String, String> = mapOf(),
|
||||
referer: String? = null,
|
||||
params: Map<String, String> = mapOf(),
|
||||
cookies: Map<String, String> = mapOf(),
|
||||
data: Map<String, String?> = DEFAULT_DATA,
|
||||
allowRedirects: Boolean = true,
|
||||
cacheTime: Int = DEFAULT_TIME,
|
||||
cacheUnit: TimeUnit = DEFAULT_TIME_UNIT,
|
||||
timeout: Long = 0L
|
||||
): Response {
|
||||
val client = baseClient
|
||||
.newBuilder()
|
||||
.followRedirects(allowRedirects)
|
||||
.followSslRedirects(allowRedirects)
|
||||
.callTimeout(timeout, TimeUnit.SECONDS)
|
||||
.build()
|
||||
val request = putRequestCreator(url, headers, referer, params, cookies, data, cacheTime, cacheUnit)
|
||||
return client.newCall(request).execute()
|
||||
}
|
||||
|
|
|
@ -0,0 +1,874 @@
|
|||
package com.lagradost.cloudstream3.syncproviders
|
||||
|
||||
import android.content.Context
|
||||
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.mvvm.logError
|
||||
import com.lagradost.cloudstream3.network.post
|
||||
import com.lagradost.cloudstream3.network.text
|
||||
import com.lagradost.cloudstream3.syncproviders.OAuth2Interface.Companion.appString
|
||||
import com.lagradost.cloudstream3.syncproviders.OAuth2Interface.Companion.maxStale
|
||||
import com.lagradost.cloudstream3.syncproviders.OAuth2Interface.Companion.unixTime
|
||||
import com.lagradost.cloudstream3.utils.AppUtils.openBrowser
|
||||
import com.lagradost.cloudstream3.utils.AppUtils.splitQuery
|
||||
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
||||
import com.lagradost.cloudstream3.utils.DataStore.getKey
|
||||
import com.lagradost.cloudstream3.utils.DataStore.getKeys
|
||||
import com.lagradost.cloudstream3.utils.DataStore.removeKeys
|
||||
import com.lagradost.cloudstream3.utils.DataStore.setKey
|
||||
import com.lagradost.cloudstream3.utils.DataStore.toKotlinObject
|
||||
import java.net.URL
|
||||
import java.util.*
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
class AniListApi(var accountId: String) : OAuth2Interface {
|
||||
override val name: String
|
||||
get() = "AniList"
|
||||
override val key: String
|
||||
get() = "6871"
|
||||
override val redirectUrl: String
|
||||
get() = "anilistlogin"
|
||||
|
||||
override fun logOut(context: Context) {
|
||||
context.removeKeys(accountId)
|
||||
}
|
||||
|
||||
override fun loginInfo(context: Context): OAuth2Interface.LoginInfo? {
|
||||
// context.getUser(true)?.
|
||||
context.getKey<AniListUser>(accountId, ANILIST_USER_KEY)?.let { user ->
|
||||
return OAuth2Interface.LoginInfo(profilePicture = user.picture, name = user.name)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
override fun authenticate(context: Context) {
|
||||
val request = "https://anilist.co/api/v2/oauth/authorize?client_id=$key&response_type=token"
|
||||
context.openBrowser(request)
|
||||
}
|
||||
|
||||
override fun handleRedirect(context: Context, url: String) {
|
||||
try {
|
||||
val sanitizer =
|
||||
splitQuery(URL(url.replace(appString, "https").replace("/#", "?"))) // FIX ERROR
|
||||
val token = sanitizer["access_token"]!!
|
||||
val expiresIn = sanitizer["expires_in"]!!
|
||||
|
||||
val endTime = unixTime + expiresIn.toLong()
|
||||
|
||||
context.setKey(accountId, ANILIST_UNIXTIME_KEY, endTime)
|
||||
context.setKey(accountId, ANILIST_TOKEN_KEY, token)
|
||||
context.setKey(ANILIST_SHOULD_UPDATE_LIST, true)
|
||||
ioSafe {
|
||||
context.getUser()
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private val aniListStatusString = arrayOf("CURRENT", "COMPLETED", "PAUSED", "DROPPED", "PLANNING", "REPEATING")
|
||||
|
||||
const val ANILIST_UNIXTIME_KEY: String = "anilist_unixtime" // When token expires
|
||||
const val ANILIST_TOKEN_KEY: String = "anilist_token" // anilist token for api
|
||||
const val ANILIST_USER_KEY: String = "anilist_user" // user data like profile
|
||||
const val ANILIST_CACHED_LIST: String = "anilist_cached_list"
|
||||
const val ANILIST_SHOULD_UPDATE_LIST: String = "anilist_should_update_list"
|
||||
}
|
||||
|
||||
|
||||
private val mapper = JsonMapper.builder().addModule(KotlinModule())
|
||||
.configure(DeserializationFeature.FAIL_ON_UNKNOWN_PROPERTIES, false).build()!!
|
||||
|
||||
|
||||
// Changing names of these will show up in UI
|
||||
enum class AniListStatusType(var value: Int) {
|
||||
Watching(0),
|
||||
Completed(1),
|
||||
Paused(2),
|
||||
Dropped(3),
|
||||
Planning(4),
|
||||
Rewatching(5),
|
||||
None(-1)
|
||||
}
|
||||
|
||||
fun fromIntToAnimeStatus(inp: Int): AniListStatusType {//= AniListStatusType.values().first { it.value == inp }
|
||||
return when (inp) {
|
||||
-1 -> AniListStatusType.None
|
||||
0 -> AniListStatusType.Watching
|
||||
1 -> AniListStatusType.Completed
|
||||
2 -> AniListStatusType.Paused
|
||||
3 -> AniListStatusType.Dropped
|
||||
4 -> AniListStatusType.Planning
|
||||
5 -> AniListStatusType.Rewatching
|
||||
else -> AniListStatusType.None
|
||||
}
|
||||
}
|
||||
|
||||
fun convertAnilistStringToStatus(string: String): AniListStatusType {
|
||||
return fromIntToAnimeStatus(aniListStatusString.indexOf(string))
|
||||
}
|
||||
|
||||
fun Context.initGetUser() {
|
||||
if (getKey<String>(accountId, ANILIST_TOKEN_KEY, null) == null) return
|
||||
ioSafe {
|
||||
getUser()
|
||||
}
|
||||
}
|
||||
|
||||
private fun Context.checkToken(): Boolean {
|
||||
if (unixTime > getKey(accountId,
|
||||
ANILIST_UNIXTIME_KEY, 0L
|
||||
)!!
|
||||
) {
|
||||
/*getCurrentActivity()?.runOnUiThread {
|
||||
val alertDialog: AlertDialog? = activity?.let {
|
||||
val builder = AlertDialog.Builder(it, R.style.AlertDialogCustom)
|
||||
builder.apply {
|
||||
setPositiveButton(
|
||||
"Login"
|
||||
) { dialog, id ->
|
||||
authenticateAniList()
|
||||
}
|
||||
setNegativeButton(
|
||||
"Cancel"
|
||||
) { dialog, id ->
|
||||
// User cancelled the dialog
|
||||
}
|
||||
}
|
||||
// Set other dialog properties
|
||||
builder.setTitle("AniList token has expired")
|
||||
|
||||
// Create the AlertDialog
|
||||
builder.create()
|
||||
}
|
||||
alertDialog?.show()
|
||||
}*/
|
||||
return true
|
||||
} else {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
private fun fixName(name: String): String {
|
||||
return name.toLowerCase(Locale.ROOT).replace(" ", "").replace("[^a-zA-Z0-9]".toRegex(), "")
|
||||
}
|
||||
|
||||
private fun searchShows(name: String): GetSearchRoot? {
|
||||
try {
|
||||
val query = """
|
||||
query (${"$"}id: Int, ${"$"}page: Int, ${"$"}search: String, ${"$"}type: MediaType) {
|
||||
Page (page: ${"$"}page, perPage: 10) {
|
||||
media (id: ${"$"}id, search: ${"$"}search, type: ${"$"}type) {
|
||||
id
|
||||
idMal
|
||||
seasonYear
|
||||
startDate { year month day }
|
||||
title {
|
||||
romaji
|
||||
}
|
||||
averageScore
|
||||
meanScore
|
||||
nextAiringEpisode {
|
||||
timeUntilAiring
|
||||
episode
|
||||
}
|
||||
trailer { id site thumbnail }
|
||||
bannerImage
|
||||
recommendations {
|
||||
nodes {
|
||||
id
|
||||
mediaRecommendation {
|
||||
id
|
||||
title {
|
||||
english
|
||||
romaji
|
||||
}
|
||||
idMal
|
||||
coverImage { medium large }
|
||||
averageScore
|
||||
}
|
||||
}
|
||||
}
|
||||
relations {
|
||||
edges {
|
||||
id
|
||||
relationType(version: 2)
|
||||
node {
|
||||
format
|
||||
id
|
||||
idMal
|
||||
coverImage { medium large }
|
||||
averageScore
|
||||
title {
|
||||
english
|
||||
romaji
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
val data =
|
||||
mapOf("query" to query, "variables" to mapper.writeValueAsString(mapOf("search" to name, "page" to 1, "type" to "ANIME")) )
|
||||
|
||||
val res = post(
|
||||
"https://graphql.anilist.co/",
|
||||
//headers = mapOf(),
|
||||
data = data,//(if (vars == null) mapOf("query" to q) else mapOf("query" to q, "variables" to vars))
|
||||
timeout = 5000 // REASONABLE TIMEOUT
|
||||
).text.replace("\\", "")
|
||||
return res.toKotlinObject()
|
||||
} catch (e: Exception) {
|
||||
logError(e)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Should use https://gist.github.com/purplepinapples/5dc60f15f2837bf1cea71b089cfeaa0a
|
||||
fun getShowId(malId: String?, name: String, year: Int?): GetSearchMedia? {
|
||||
// Strips these from the name
|
||||
val blackList = listOf(
|
||||
"TV Dubbed",
|
||||
"(Dub)",
|
||||
"Subbed",
|
||||
"(TV)",
|
||||
"(Uncensored)",
|
||||
"(Censored)",
|
||||
"(\\d+)" // year
|
||||
)
|
||||
val blackListRegex =
|
||||
Regex(""" (${blackList.joinToString(separator = "|").replace("(", "\\(").replace(")", "\\)")})""")
|
||||
//println("NAME $name NEW NAME ${name.replace(blackListRegex, "")}")
|
||||
val shows = searchShows(name.replace(blackListRegex, ""))
|
||||
|
||||
shows?.data?.Page?.media?.find {
|
||||
malId ?: "NONE" == it.idMal.toString()
|
||||
}?.let { return it }
|
||||
|
||||
val filtered =
|
||||
shows?.data?.Page?.media?.filter {
|
||||
(
|
||||
it.startDate.year ?: year.toString() == year.toString()
|
||||
|| year == null
|
||||
)
|
||||
}
|
||||
filtered?.forEach {
|
||||
if (fixName(it.title.romaji) == fixName(name)) return it
|
||||
}
|
||||
|
||||
return filtered?.firstOrNull()
|
||||
}
|
||||
|
||||
private fun Context.postApi(url: String, q: String, cache: Boolean = false): String {
|
||||
return try {
|
||||
if (!checkToken()) {
|
||||
// println("VARS_ " + vars)
|
||||
post(
|
||||
"https://graphql.anilist.co/",
|
||||
headers = mapOf(
|
||||
"Authorization" to "Bearer " + getKey(
|
||||
accountId,
|
||||
ANILIST_TOKEN_KEY,
|
||||
""
|
||||
)!!,
|
||||
if (cache) "Cache-Control" to "max-stale=$maxStale" else "Cache-Control" to "no-cache"
|
||||
),
|
||||
cacheTime = 0,
|
||||
data = mapOf("query" to q),//(if (vars == null) mapOf("query" to q) else mapOf("query" to q, "variables" to vars))
|
||||
timeout = 5 // REASONABLE TIMEOUT
|
||||
).text.replace("\\/", "/")
|
||||
} else {
|
||||
""
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logError(e)
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
data class MediaRecommendation(
|
||||
@JsonProperty("id") val id: Int,
|
||||
@JsonProperty("title") val title: Title,
|
||||
@JsonProperty("idMal") val idMal: Int?,
|
||||
@JsonProperty("coverImage") val coverImage: CoverImage,
|
||||
@JsonProperty("averageScore") val averageScore: Int?
|
||||
)
|
||||
|
||||
fun Context.getDataAboutId(id: Int): AniListTitleHolder? {
|
||||
val q =
|
||||
"""query (${'$'}id: Int = $id) { # Define which variables will be used in the query (id)
|
||||
Media (id: ${'$'}id, type: ANIME) { # Insert our variables into the query arguments (id) (type: ANIME is hard-coded in the query)
|
||||
id
|
||||
episodes
|
||||
isFavourite
|
||||
mediaListEntry {
|
||||
progress
|
||||
status
|
||||
score (format: POINT_10)
|
||||
}
|
||||
title {
|
||||
english
|
||||
romaji
|
||||
}
|
||||
}
|
||||
}"""
|
||||
try {
|
||||
val data = postApi("https://graphql.anilist.co", q, true)
|
||||
var d: GetDataRoot? = null
|
||||
try {
|
||||
d = mapper.readValue<GetDataRoot>(data)
|
||||
} catch (e: Exception) {
|
||||
logError(e)
|
||||
println("AniList json failed")
|
||||
}
|
||||
if (d == null) {
|
||||
return null
|
||||
}
|
||||
|
||||
val main = d.data.Media
|
||||
if (main.mediaListEntry != null) {
|
||||
return AniListTitleHolder(
|
||||
title = main.title,
|
||||
id = id,
|
||||
isFavourite = main.isFavourite,
|
||||
progress = main.mediaListEntry.progress,
|
||||
episodes = main.episodes,
|
||||
score = main.mediaListEntry.score,
|
||||
type = fromIntToAnimeStatus(aniListStatusString.indexOf(main.mediaListEntry.status)),
|
||||
)
|
||||
} else {
|
||||
return AniListTitleHolder(
|
||||
title = main.title,
|
||||
id = id,
|
||||
isFavourite = main.isFavourite,
|
||||
progress = 0,
|
||||
episodes = main.episodes,
|
||||
score = 0,
|
||||
type = AniListStatusType.None,
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logError(e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
data class FullAnilistList(
|
||||
@JsonProperty("data") val data: Data
|
||||
)
|
||||
|
||||
data class CompletedAt(
|
||||
@JsonProperty("year") val year: Int,
|
||||
@JsonProperty("month") val month: Int,
|
||||
@JsonProperty("day") val day: Int
|
||||
)
|
||||
|
||||
data class StartedAt(
|
||||
@JsonProperty("year") val year: String?,
|
||||
@JsonProperty("month") val month: String?,
|
||||
@JsonProperty("day") val day: String?
|
||||
)
|
||||
|
||||
data class Title(
|
||||
@JsonProperty("english") val english: String?,
|
||||
@JsonProperty("romaji") val romaji: String?
|
||||
)
|
||||
|
||||
data class CoverImage(
|
||||
@JsonProperty("medium") val medium: String,
|
||||
@JsonProperty("large") val large: String?
|
||||
)
|
||||
|
||||
data class Media(
|
||||
@JsonProperty("id") val id: Int,
|
||||
@JsonProperty("idMal") val idMal: Int?,
|
||||
@JsonProperty("season") val season: String?,
|
||||
@JsonProperty("seasonYear") val seasonYear: Int,
|
||||
@JsonProperty("format") val format: String?,
|
||||
//@JsonProperty("source") val source: String,
|
||||
@JsonProperty("episodes") val episodes: Int,
|
||||
@JsonProperty("title") val title: Title,
|
||||
//@JsonProperty("description") val description: String,
|
||||
@JsonProperty("coverImage") val coverImage: CoverImage,
|
||||
@JsonProperty("synonyms") val synonyms: List<String>,
|
||||
@JsonProperty("nextAiringEpisode") val nextAiringEpisode: SeasonNextAiringEpisode?,
|
||||
)
|
||||
|
||||
data class Entries(
|
||||
@JsonProperty("status") val status: String?,
|
||||
@JsonProperty("completedAt") val completedAt: CompletedAt,
|
||||
@JsonProperty("startedAt") val startedAt: StartedAt,
|
||||
@JsonProperty("updatedAt") val updatedAt: Int,
|
||||
@JsonProperty("progress") val progress: Int,
|
||||
@JsonProperty("score") val score: Int,
|
||||
@JsonProperty("private") val private: Boolean,
|
||||
@JsonProperty("media") val media: Media
|
||||
)
|
||||
|
||||
data class Lists(
|
||||
@JsonProperty("status") val status: String?,
|
||||
@JsonProperty("entries") val entries: List<Entries>
|
||||
)
|
||||
|
||||
data class MediaListCollection(
|
||||
@JsonProperty("lists") val lists: List<Lists>
|
||||
)
|
||||
|
||||
data class Data(
|
||||
@JsonProperty("MediaListCollection") val MediaListCollection: MediaListCollection
|
||||
)
|
||||
|
||||
fun Context.getAnilistListCached(): Array<Lists>? {
|
||||
return getKey(ANILIST_CACHED_LIST) as? Array<Lists>
|
||||
}
|
||||
|
||||
fun Context.getAnilistAnimeListSmart(): Array<Lists>? {
|
||||
if (getKey<String>(
|
||||
accountId,
|
||||
ANILIST_TOKEN_KEY,
|
||||
null
|
||||
) == null
|
||||
) return null
|
||||
|
||||
if (checkToken()) return null
|
||||
return if (getKey(ANILIST_SHOULD_UPDATE_LIST, true) == true) {
|
||||
val list = getFullAnilistList()?.data?.MediaListCollection?.lists?.toTypedArray()
|
||||
if (list != null) {
|
||||
setKey(ANILIST_CACHED_LIST, list)
|
||||
setKey(ANILIST_SHOULD_UPDATE_LIST, false)
|
||||
}
|
||||
list
|
||||
} else {
|
||||
getAnilistListCached()
|
||||
}
|
||||
}
|
||||
|
||||
private fun Context.getFullAnilistList(): FullAnilistList? {
|
||||
try {
|
||||
var userID: Int? = null
|
||||
/** WARNING ASSUMES ONE USER! **/
|
||||
getKeys(ANILIST_USER_KEY).forEach { key ->
|
||||
getKey<AniListUser>(key, null)?.let {
|
||||
userID = it.id
|
||||
}
|
||||
}
|
||||
|
||||
val fixedUserID = userID ?: return null
|
||||
val mediaType = "ANIME"
|
||||
|
||||
val query = """
|
||||
query (${'$'}userID: Int = $fixedUserID, ${'$'}MEDIA: MediaType = $mediaType) {
|
||||
MediaListCollection (userId: ${'$'}userID, type: ${'$'}MEDIA) {
|
||||
lists {
|
||||
status
|
||||
entries
|
||||
{
|
||||
status
|
||||
completedAt { year month day }
|
||||
startedAt { year month day }
|
||||
updatedAt
|
||||
progress
|
||||
score
|
||||
private
|
||||
media
|
||||
{
|
||||
id
|
||||
idMal
|
||||
season
|
||||
seasonYear
|
||||
format
|
||||
episodes
|
||||
chapters
|
||||
title
|
||||
{
|
||||
english
|
||||
romaji
|
||||
}
|
||||
coverImage { medium }
|
||||
synonyms
|
||||
nextAiringEpisode {
|
||||
timeUntilAiring
|
||||
episode
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
"""
|
||||
val text = postApi("https://graphql.anilist.co", query)
|
||||
return text.toKotlinObject()
|
||||
|
||||
} catch (e: Exception) {
|
||||
logError(e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
fun Context.toggleLike(id: Int): Boolean {
|
||||
val q = """mutation (${'$'}animeId: Int = $id) {
|
||||
ToggleFavourite (animeId: ${'$'}animeId) {
|
||||
anime {
|
||||
nodes {
|
||||
id
|
||||
title {
|
||||
romaji
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}"""
|
||||
val data = postApi("https://graphql.anilist.co", q)
|
||||
return data != ""
|
||||
}
|
||||
|
||||
fun Context.postDataAboutId(id: Int, type: AniListStatusType, score: Int, progress: Int): Boolean {
|
||||
try {
|
||||
val q =
|
||||
"""mutation (${'$'}id: Int = $id, ${'$'}status: MediaListStatus = ${
|
||||
aniListStatusString[maxOf(
|
||||
0,
|
||||
type.value
|
||||
)]
|
||||
}, ${'$'}scoreRaw: Int = ${score * 10}, ${'$'}progress: Int = $progress) {
|
||||
SaveMediaListEntry (mediaId: ${'$'}id, status: ${'$'}status, scoreRaw: ${'$'}scoreRaw, progress: ${'$'}progress) {
|
||||
id
|
||||
status
|
||||
progress
|
||||
score
|
||||
}
|
||||
}"""
|
||||
val data = postApi("https://graphql.anilist.co", q)
|
||||
return data != ""
|
||||
} catch (e: Exception) {
|
||||
logError(e)
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
fun Context.getUser(setSettings: Boolean = true): AniListUser? {
|
||||
val q = """
|
||||
{
|
||||
Viewer {
|
||||
id
|
||||
name
|
||||
avatar {
|
||||
large
|
||||
}
|
||||
favourites {
|
||||
anime {
|
||||
nodes {
|
||||
id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}"""
|
||||
try {
|
||||
val data = postApi("https://graphql.anilist.co", q)
|
||||
if (data == "") return null
|
||||
val userData = mapper.readValue<AniListRoot>(data)
|
||||
val u = userData.data.Viewer
|
||||
val user = AniListUser(
|
||||
u.id,
|
||||
u.name,
|
||||
u.avatar.large,
|
||||
)
|
||||
if (setSettings) {
|
||||
setKey(accountId, ANILIST_USER_KEY, user)
|
||||
}
|
||||
/* // TODO FIX FAVS
|
||||
for(i in u.favourites.anime.nodes) {
|
||||
println("FFAV:" + i.id)
|
||||
}*/
|
||||
return user
|
||||
} catch (e: java.lang.Exception) {
|
||||
logError(e)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
private fun getSeason(id: Int): SeasonResponse? {
|
||||
val q: String = """
|
||||
query (${'$'}id: Int = $id) {
|
||||
Media (id: ${'$'}id, type: ANIME) {
|
||||
id
|
||||
idMal
|
||||
relations {
|
||||
edges {
|
||||
id
|
||||
relationType(version: 2)
|
||||
node {
|
||||
id
|
||||
format
|
||||
nextAiringEpisode {
|
||||
timeUntilAiring
|
||||
episode
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
nextAiringEpisode {
|
||||
timeUntilAiring
|
||||
episode
|
||||
}
|
||||
format
|
||||
}
|
||||
}
|
||||
"""
|
||||
|
||||
val data = post(
|
||||
"https://graphql.anilist.co",
|
||||
data = mapOf("query" to q),
|
||||
cacheTime = 0,
|
||||
).text
|
||||
if (data == "") return null
|
||||
return try {
|
||||
mapper.readValue(data)
|
||||
} catch (e: Exception) {
|
||||
logError(e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun getAllSeasons(id: Int): List<SeasonResponse?> {
|
||||
val seasons = mutableListOf<SeasonResponse?>()
|
||||
fun getSeasonRecursive(id: Int) {
|
||||
val season = getSeason(id)
|
||||
if (season != null) {
|
||||
seasons.add(season)
|
||||
if (season.data.Media.format?.startsWith("TV") == true) {
|
||||
season.data.Media.relations.edges.forEach {
|
||||
if (it.node.format != null) {
|
||||
if (it.relationType == "SEQUEL" && it.node.format.startsWith("TV")) {
|
||||
getSeasonRecursive(it.node.id)
|
||||
return@forEach
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
getSeasonRecursive(id)
|
||||
return seasons.toList()
|
||||
}
|
||||
|
||||
fun secondsToReadable(seconds: Int, completedValue: String): String {
|
||||
var secondsLong = seconds.toLong()
|
||||
val days = TimeUnit.SECONDS
|
||||
.toDays(secondsLong)
|
||||
secondsLong -= TimeUnit.DAYS.toSeconds(days)
|
||||
|
||||
val hours = TimeUnit.SECONDS
|
||||
.toHours(secondsLong)
|
||||
secondsLong -= TimeUnit.HOURS.toSeconds(hours)
|
||||
|
||||
val minutes = TimeUnit.SECONDS
|
||||
.toMinutes(secondsLong)
|
||||
secondsLong -= TimeUnit.MINUTES.toSeconds(minutes)
|
||||
if (minutes < 0) {
|
||||
return completedValue
|
||||
}
|
||||
//println("$days $hours $minutes")
|
||||
return "${if (days != 0L) "$days" + "d " else ""}${if (hours != 0L) "$hours" + "h " else ""}${minutes}m"
|
||||
}
|
||||
|
||||
|
||||
data class SeasonResponse(
|
||||
@JsonProperty("data") val data: SeasonData,
|
||||
)
|
||||
|
||||
data class SeasonData(
|
||||
@JsonProperty("Media") val Media: SeasonMedia,
|
||||
)
|
||||
|
||||
data class SeasonMedia(
|
||||
@JsonProperty("id") val id: Int,
|
||||
@JsonProperty("idMal") val idMal: Int?,
|
||||
@JsonProperty("format") val format: String?,
|
||||
@JsonProperty("nextAiringEpisode") val nextAiringEpisode: SeasonNextAiringEpisode?,
|
||||
@JsonProperty("relations") val relations: SeasonEdges,
|
||||
)
|
||||
|
||||
data class SeasonNextAiringEpisode(
|
||||
@JsonProperty("episode") val episode: Int,
|
||||
@JsonProperty("timeUntilAiring") val timeUntilAiring: Int,
|
||||
)
|
||||
|
||||
data class SeasonEdges(
|
||||
@JsonProperty("edges") val edges: List<SeasonEdge>,
|
||||
)
|
||||
|
||||
data class SeasonEdge(
|
||||
@JsonProperty("id") val id: Int,
|
||||
@JsonProperty("relationType") val relationType: String,
|
||||
@JsonProperty("node") val node: SeasonNode,
|
||||
)
|
||||
|
||||
data class AniListFavoritesMediaConnection(
|
||||
@JsonProperty("nodes") val nodes: List<LikeNode>,
|
||||
)
|
||||
|
||||
data class AniListFavourites(
|
||||
@JsonProperty("anime") val anime: AniListFavoritesMediaConnection,
|
||||
)
|
||||
|
||||
data class SeasonNode(
|
||||
@JsonProperty("id") val id: Int,
|
||||
@JsonProperty("format") val format: String?,
|
||||
@JsonProperty("title") val title: AniListApi.Title,
|
||||
@JsonProperty("idMal") val idMal: Int?,
|
||||
@JsonProperty("coverImage") val coverImage: AniListApi.CoverImage,
|
||||
@JsonProperty("averageScore") val averageScore: Int?
|
||||
// @JsonProperty("nextAiringEpisode") val nextAiringEpisode: SeasonNextAiringEpisode?,
|
||||
)
|
||||
|
||||
data class AniListAvatar(
|
||||
@JsonProperty("large") val large: String,
|
||||
)
|
||||
|
||||
data class AniListViewer(
|
||||
@JsonProperty("id") val id: Int,
|
||||
@JsonProperty("name") val name: String,
|
||||
@JsonProperty("avatar") val avatar: AniListAvatar,
|
||||
@JsonProperty("favourites") val favourites: AniListFavourites,
|
||||
)
|
||||
|
||||
data class AniListData(
|
||||
@JsonProperty("Viewer") val Viewer: AniListViewer,
|
||||
)
|
||||
|
||||
data class AniListRoot(
|
||||
@JsonProperty("data") val data: AniListData,
|
||||
)
|
||||
|
||||
data class AniListUser(
|
||||
@JsonProperty("id") val id: Int,
|
||||
@JsonProperty("name") val name: String,
|
||||
@JsonProperty("picture") val picture: String,
|
||||
)
|
||||
|
||||
data class LikeNode(
|
||||
@JsonProperty("id") val id: Int,
|
||||
//@JsonProperty("idMal") public int idMal;
|
||||
)
|
||||
|
||||
data class LikePageInfo(
|
||||
@JsonProperty("total") val total: Int,
|
||||
@JsonProperty("currentPage") val currentPage: Int,
|
||||
@JsonProperty("lastPage") val lastPage: Int,
|
||||
@JsonProperty("perPage") val perPage: Int,
|
||||
@JsonProperty("hasNextPage") val hasNextPage: Boolean,
|
||||
)
|
||||
|
||||
data class LikeAnime(
|
||||
@JsonProperty("nodes") val nodes: List<LikeNode>,
|
||||
@JsonProperty("pageInfo") val pageInfo: LikePageInfo,
|
||||
)
|
||||
|
||||
data class LikeFavourites(
|
||||
@JsonProperty("anime") val anime: LikeAnime,
|
||||
)
|
||||
|
||||
data class LikeViewer(
|
||||
@JsonProperty("favourites") val favourites: LikeFavourites,
|
||||
)
|
||||
|
||||
data class LikeData(
|
||||
@JsonProperty("Viewer") val Viewer: LikeViewer,
|
||||
)
|
||||
|
||||
data class LikeRoot(
|
||||
@JsonProperty("data") val data: LikeData,
|
||||
)
|
||||
|
||||
data class Recommendation(
|
||||
@JsonProperty("title") val title: String,
|
||||
@JsonProperty("idMal") val idMal: Int,
|
||||
@JsonProperty("poster") val poster: String,
|
||||
@JsonProperty("averageScore") val averageScore: Int?
|
||||
)
|
||||
|
||||
data class AniListTitleHolder(
|
||||
@JsonProperty("title") val title: Title,
|
||||
@JsonProperty("isFavourite") val isFavourite: Boolean,
|
||||
@JsonProperty("id") val id: Int,
|
||||
@JsonProperty("progress") val progress: Int,
|
||||
@JsonProperty("episodes") val episodes: Int,
|
||||
@JsonProperty("score") val score: Int,
|
||||
@JsonProperty("type") val type: AniListStatusType,
|
||||
)
|
||||
|
||||
data class GetDataMediaListEntry(
|
||||
@JsonProperty("progress") val progress: Int,
|
||||
@JsonProperty("status") val status: String,
|
||||
@JsonProperty("score") val score: Int,
|
||||
)
|
||||
|
||||
data class Nodes(
|
||||
@JsonProperty("id") val id: Int,
|
||||
@JsonProperty("mediaRecommendation") val mediaRecommendation: MediaRecommendation?
|
||||
)
|
||||
|
||||
data class GetDataMedia(
|
||||
@JsonProperty("isFavourite") val isFavourite: Boolean,
|
||||
@JsonProperty("episodes") val episodes: Int,
|
||||
@JsonProperty("title") val title: Title,
|
||||
@JsonProperty("mediaListEntry") val mediaListEntry: GetDataMediaListEntry?
|
||||
)
|
||||
|
||||
data class Recommendations(
|
||||
@JsonProperty("nodes") val nodes: List<Nodes>
|
||||
)
|
||||
|
||||
data class GetDataData(
|
||||
@JsonProperty("Media") val Media: GetDataMedia,
|
||||
)
|
||||
|
||||
data class GetDataRoot(
|
||||
@JsonProperty("data") val data: GetDataData,
|
||||
)
|
||||
|
||||
data class GetSearchTitle(
|
||||
@JsonProperty("romaji") val romaji: String,
|
||||
)
|
||||
|
||||
data class TrailerObject(
|
||||
@JsonProperty("id") val id: String?,
|
||||
@JsonProperty("thumbnail") val thumbnail: String?,
|
||||
@JsonProperty("site") val site: String?,
|
||||
)
|
||||
|
||||
data class GetSearchMedia(
|
||||
@JsonProperty("id") val id: Int,
|
||||
@JsonProperty("idMal") val idMal: Int?,
|
||||
@JsonProperty("seasonYear") val seasonYear: Int,
|
||||
@JsonProperty("title") val title: GetSearchTitle,
|
||||
@JsonProperty("startDate") val startDate: StartedAt,
|
||||
@JsonProperty("averageScore") val averageScore: Int?,
|
||||
@JsonProperty("meanScore") val meanScore: Int?,
|
||||
@JsonProperty("bannerImage") val bannerImage: String?,
|
||||
@JsonProperty("trailer") val trailer: TrailerObject?,
|
||||
@JsonProperty("nextAiringEpisode") val nextAiringEpisode: SeasonNextAiringEpisode?,
|
||||
@JsonProperty("recommendations") val recommendations: Recommendations?,
|
||||
@JsonProperty("relations") val relations: SeasonEdges
|
||||
)
|
||||
|
||||
data class GetSearchPage(
|
||||
@JsonProperty("Page") val Page: GetSearchData,
|
||||
)
|
||||
|
||||
data class GetSearchData(
|
||||
@JsonProperty("media") val media: List<GetSearchMedia>,
|
||||
)
|
||||
|
||||
data class GetSearchRoot(
|
||||
@JsonProperty("data") val data: GetSearchPage,
|
||||
)
|
||||
}
|
|
@ -1,13 +1,29 @@
|
|||
package com.lagradost.cloudstream3.syncproviders
|
||||
|
||||
import android.content.Context
|
||||
|
||||
//TODO dropbox sync
|
||||
class Dropbox : OAuth2Interface {
|
||||
override val name: String
|
||||
get() = "Dropbox"
|
||||
override val key: String
|
||||
get() = "zlqsamadlwydvb2"
|
||||
override val redirectUrl: String
|
||||
get() = "dropboxlogin"
|
||||
|
||||
override fun handleRedirect(url: String) {
|
||||
override fun authenticate(context: Context) {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override fun handleRedirect(context: Context,url: String) {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override fun logOut(context: Context) {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
|
||||
override fun loginInfo(context: Context): OAuth2Interface.LoginInfo? {
|
||||
TODO("Not yet implemented")
|
||||
}
|
||||
}
|
|
@ -0,0 +1,547 @@
|
|||
package com.lagradost.cloudstream3.syncproviders
|
||||
|
||||
import android.content.Context
|
||||
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.mvvm.logError
|
||||
import com.lagradost.cloudstream3.network.get
|
||||
import com.lagradost.cloudstream3.network.post
|
||||
import com.lagradost.cloudstream3.network.put
|
||||
import com.lagradost.cloudstream3.network.text
|
||||
import com.lagradost.cloudstream3.syncproviders.OAuth2Interface.Companion.appString
|
||||
import com.lagradost.cloudstream3.syncproviders.OAuth2Interface.Companion.secondsToReadable
|
||||
import com.lagradost.cloudstream3.syncproviders.OAuth2Interface.Companion.unixTime
|
||||
import com.lagradost.cloudstream3.utils.AppUtils.openBrowser
|
||||
import com.lagradost.cloudstream3.utils.AppUtils.splitQuery
|
||||
import com.lagradost.cloudstream3.utils.Coroutines.ioSafe
|
||||
import com.lagradost.cloudstream3.utils.DataStore.getKey
|
||||
import com.lagradost.cloudstream3.utils.DataStore.removeKeys
|
||||
import com.lagradost.cloudstream3.utils.DataStore.setKey
|
||||
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.*
|
||||
|
||||
class MALApi(var accountId: String) : OAuth2Interface {
|
||||
override val name: String
|
||||
get() = "MAL"
|
||||
override val key: String
|
||||
get() = "1714d6f2f4f7cc19644384f8c4629910"
|
||||
override val redirectUrl: String
|
||||
get() = "mallogin"
|
||||
|
||||
override fun logOut(context: Context) {
|
||||
context.removeKeys(accountId)
|
||||
}
|
||||
|
||||
override fun loginInfo(context: Context): OAuth2Interface.LoginInfo? {
|
||||
//context.getMalUser(true)?
|
||||
context.getKey<MalUser>(accountId, MAL_USER_KEY)?.let { user ->
|
||||
return OAuth2Interface.LoginInfo(profilePicture = user.picture, name = user.name)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
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(context: Context,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 = 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 != "") {
|
||||
context.storeToken(res)
|
||||
context.getMalUser()
|
||||
context.setKey(MAL_SHOULD_UPDATE_LIST, true)
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
e.printStackTrace()
|
||||
}
|
||||
}
|
||||
|
||||
override fun authenticate(context: Context) {
|
||||
// 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"
|
||||
context.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 Context.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 fun Context.refreshToken() {
|
||||
try {
|
||||
val res = 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 Context.getMalAnimeListCached(): Array<Data>? {
|
||||
return getKey(MAL_CACHED_LIST) as? Array<Data>
|
||||
}
|
||||
|
||||
fun Context.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 fun Context.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 fun Context.getMalAnimeListSlice(offset: Int = 0): MalList? {
|
||||
val user = "@me"
|
||||
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 = get(
|
||||
url, headers = mapOf(
|
||||
"Authorization" to "Bearer " + getKey<String>(
|
||||
accountId,
|
||||
MAL_TOKEN_KEY
|
||||
)!!,
|
||||
), cacheTime = 0
|
||||
).text
|
||||
res.toKotlinObject()
|
||||
} catch (e: Exception) {
|
||||
logError(e)
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun Context.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 = get(
|
||||
url, headers = mapOf(
|
||||
"Authorization" to "Bearer " + getKey<String>(
|
||||
accountId,
|
||||
MAL_TOKEN_KEY
|
||||
)!!
|
||||
), cacheTime = 0
|
||||
).text
|
||||
mapper.readValue<MalAnime>(res)
|
||||
} catch (e: Exception) {
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
fun Context.setAllMalData() {
|
||||
val user = "@me"
|
||||
var isDone = false
|
||||
var index = 0
|
||||
allTitles.clear()
|
||||
checkMalToken()
|
||||
while (!isDone) {
|
||||
val res = 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 fun Context.checkMalToken() {
|
||||
if (unixTime > getKey(
|
||||
accountId,
|
||||
MAL_UNIXTIME_KEY
|
||||
) ?: 0L
|
||||
) {
|
||||
refreshToken()
|
||||
}
|
||||
}
|
||||
|
||||
fun Context.getMalUser(setSettings: Boolean = true): MalUser? {
|
||||
checkMalToken()
|
||||
return try {
|
||||
val res = 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)
|
||||
}
|
||||
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)
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
}
|
||||
|
||||
fun Context.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 fun Context.setScoreRequest(
|
||||
id: Int,
|
||||
status: String? = null,
|
||||
score: Int? = null,
|
||||
num_watched_episodes: Int? = null,
|
||||
): String {
|
||||
return try {
|
||||
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,
|
||||
)
|
||||
|
||||
// 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?
|
||||
)
|
||||
|
||||
data class MalTitleHolder(
|
||||
val status: MalStatus,
|
||||
val id: Int,
|
||||
val name: String,
|
||||
)
|
||||
}
|
|
@ -1,7 +1,59 @@
|
|||
package com.lagradost.cloudstream3.syncproviders
|
||||
|
||||
import android.content.Context
|
||||
import java.util.concurrent.TimeUnit
|
||||
|
||||
interface OAuth2Interface {
|
||||
val key : String
|
||||
val name : String
|
||||
val redirectUrl : String
|
||||
fun handleRedirect(url : String)
|
||||
|
||||
fun handleRedirect(context: Context, url : String)
|
||||
fun authenticate(context: Context)
|
||||
|
||||
fun loginInfo(context: Context) : LoginInfo?
|
||||
fun logOut(context: Context)
|
||||
|
||||
class LoginInfo(
|
||||
val profilePicture : String?,
|
||||
val name : String?,
|
||||
)
|
||||
|
||||
companion object {
|
||||
val malApi = MALApi("mal_account_0")
|
||||
val aniListApi = AniListApi("anilist_account_0")
|
||||
|
||||
val OAuth2Apis get() = listOf(
|
||||
malApi, aniListApi
|
||||
)
|
||||
|
||||
const val appString = "cloudstreamapp"
|
||||
|
||||
val unixTime: Long
|
||||
get() = System.currentTimeMillis() / 1000L
|
||||
val unixTimeMS: Long
|
||||
get() = System.currentTimeMillis()
|
||||
|
||||
const val maxStale = 60 * 10
|
||||
|
||||
fun secondsToReadable(seconds: Int, completedValue: String): String {
|
||||
var secondsLong = seconds.toLong()
|
||||
val days = TimeUnit.SECONDS
|
||||
.toDays(secondsLong)
|
||||
secondsLong -= TimeUnit.DAYS.toSeconds(days)
|
||||
|
||||
val hours = TimeUnit.SECONDS
|
||||
.toHours(secondsLong)
|
||||
secondsLong -= TimeUnit.HOURS.toSeconds(hours)
|
||||
|
||||
val minutes = TimeUnit.SECONDS
|
||||
.toMinutes(secondsLong)
|
||||
secondsLong -= TimeUnit.MINUTES.toSeconds(minutes)
|
||||
if (minutes < 0) {
|
||||
return completedValue
|
||||
}
|
||||
//println("$days $hours $minutes")
|
||||
return "${if (days != 0L) "$days" + "d " else ""}${if (hours != 0L) "$hours" + "h " else ""}${minutes}m"
|
||||
}
|
||||
}
|
||||
}
|
|
@ -26,6 +26,7 @@ import com.lagradost.cloudstream3.APIHolder.getApiFromNameNull
|
|||
import com.lagradost.cloudstream3.mvvm.Resource
|
||||
import com.lagradost.cloudstream3.mvvm.logError
|
||||
import com.lagradost.cloudstream3.mvvm.observe
|
||||
import com.lagradost.cloudstream3.syncproviders.OAuth2Interface
|
||||
import com.lagradost.cloudstream3.ui.APIRepository.Companion.noneApi
|
||||
import com.lagradost.cloudstream3.ui.APIRepository.Companion.randomApi
|
||||
import com.lagradost.cloudstream3.ui.AutofitRecyclerView
|
||||
|
@ -48,6 +49,7 @@ import com.lagradost.cloudstream3.utils.UIHelper.fixPaddingStatusbarView
|
|||
import com.lagradost.cloudstream3.utils.UIHelper.getGridIsCompact
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.popupMenuNoIcons
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.popupMenuNoIconsAndNoStringRes
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.setImage
|
||||
import com.lagradost.cloudstream3.widget.CenterZoomLayoutManager
|
||||
import kotlinx.android.synthetic.main.fragment_home.*
|
||||
|
||||
|
@ -420,5 +422,19 @@ class HomeFragment : Fragment() {
|
|||
//println("Caught home: " + homeViewModel.apiName.value + " at " + apiName)
|
||||
homeViewModel.loadAndCancel(apiName, currentPrefMedia)
|
||||
}
|
||||
|
||||
// nice profile pic on homepage
|
||||
home_profile_picture_holder?.isVisible = false
|
||||
context?.let { ctx ->
|
||||
for (syncApi in OAuth2Interface.OAuth2Apis) {
|
||||
val login = syncApi.loginInfo(ctx)
|
||||
val pic = login?.profilePicture
|
||||
if(pic != null) {
|
||||
home_profile_picture.setImage(pic)
|
||||
home_profile_picture_holder.isVisible = true
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -1,13 +1,15 @@
|
|||
package com.lagradost.cloudstream3.ui.settings
|
||||
|
||||
|
||||
import android.content.Intent
|
||||
import android.net.Uri
|
||||
import android.app.UiModeManager
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.res.Configuration
|
||||
import android.net.Uri
|
||||
import android.os.Bundle
|
||||
import android.os.Environment
|
||||
import android.widget.ImageView
|
||||
import android.widget.TextView
|
||||
import android.widget.Toast
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.appcompat.app.AlertDialog
|
||||
|
@ -27,6 +29,9 @@ import com.lagradost.cloudstream3.MainActivity.Companion.showToast
|
|||
import com.lagradost.cloudstream3.R
|
||||
import com.lagradost.cloudstream3.mvvm.logError
|
||||
import com.lagradost.cloudstream3.network.initRequestClient
|
||||
import com.lagradost.cloudstream3.syncproviders.OAuth2Interface
|
||||
import com.lagradost.cloudstream3.syncproviders.OAuth2Interface.Companion.aniListApi
|
||||
import com.lagradost.cloudstream3.syncproviders.OAuth2Interface.Companion.malApi
|
||||
import com.lagradost.cloudstream3.ui.APIRepository
|
||||
import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment
|
||||
import com.lagradost.cloudstream3.utils.AppUtils
|
||||
|
@ -39,6 +44,7 @@ import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showDialog
|
|||
import com.lagradost.cloudstream3.utils.SingleSelectionHelper.showMultiDialog
|
||||
import com.lagradost.cloudstream3.utils.SubtitleHelper
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.setImage
|
||||
import com.lagradost.cloudstream3.utils.VideoDownloadManager.getBasePath
|
||||
import com.lagradost.cloudstream3.utils.VideoDownloadManager.getDownloadDir
|
||||
import com.lagradost.cloudstream3.utils.VideoDownloadManager.isScopedStorage
|
||||
|
@ -61,6 +67,8 @@ class SettingsFragment : PreferenceFragmentCompat() {
|
|||
val uiModeManager = getSystemService(Context.UI_MODE_SERVICE) as UiModeManager?
|
||||
return uiModeManager?.currentModeType == Configuration.UI_MODE_TYPE_TELEVISION
|
||||
}
|
||||
|
||||
private const val accountEnabled = false
|
||||
}
|
||||
|
||||
private var beneneCount = 0
|
||||
|
@ -111,6 +119,23 @@ class SettingsFragment : PreferenceFragmentCompat() {
|
|||
Triple("🇹🇷", "Turkish", "tr")
|
||||
).sortedBy { it.second } //ye, we go alphabetical, so ppl don't put their lang on top
|
||||
|
||||
private fun showLoginInfo(context: Context, api: OAuth2Interface, info: OAuth2Interface.LoginInfo) {
|
||||
val builder =
|
||||
AlertDialog.Builder(context, R.style.AlertDialogCustom).setView(R.layout.account_managment)
|
||||
val dialog = builder.show()
|
||||
|
||||
dialog.findViewById<ImageView>(R.id.account_profile_picture)?.setImage(info.profilePicture)
|
||||
dialog.findViewById<TextView>(R.id.account_logout)?.setOnClickListener {
|
||||
it.context?.let { ctx ->
|
||||
api.logOut(ctx)
|
||||
dialog.dismiss()
|
||||
}
|
||||
}
|
||||
|
||||
dialog.findViewById<TextView>(R.id.account_name)?.text = info.name ?: context.getString(R.string.no_data)
|
||||
dialog.findViewById<TextView>(R.id.account_site)?.text = api.name
|
||||
}
|
||||
|
||||
override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
|
||||
hideKeyboard()
|
||||
setPreferencesFromResource(R.xml.settings, rootKey)
|
||||
|
@ -128,6 +153,27 @@ class SettingsFragment : PreferenceFragmentCompat() {
|
|||
val preferedMediaTypePreference = findPreference<Preference>(getString(R.string.prefer_media_type_key))!!
|
||||
val appThemePreference = findPreference<Preference>(getString(R.string.app_theme_key))!!
|
||||
|
||||
val syncApis = listOf(Pair(R.string.mal_key, malApi), Pair(R.string.anilist_key, aniListApi))
|
||||
for (sync in syncApis) {
|
||||
findPreference<Preference>(getString(sync.first))?.apply {
|
||||
isVisible = accountEnabled
|
||||
val api = sync.second
|
||||
title = getString(R.string.login_format).format(api.name, getString(R.string.account))
|
||||
setOnPreferenceClickListener { pref ->
|
||||
pref.context?.let { ctx ->
|
||||
val info = api.loginInfo(ctx)
|
||||
if (info != null) {
|
||||
showLoginInfo(ctx, api, info)
|
||||
} else {
|
||||
api.authenticate(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
return@setOnPreferenceClickListener true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
legalPreference.setOnPreferenceClickListener {
|
||||
val builder: AlertDialog.Builder = AlertDialog.Builder(it.context)
|
||||
builder.setTitle(R.string.legal_notice)
|
||||
|
|
|
@ -1,8 +1,10 @@
|
|||
package com.lagradost.cloudstream3.utils
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.ComponentName
|
||||
import android.content.ContentValues
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageManager
|
||||
import android.media.AudioAttributes
|
||||
import android.media.AudioFocusRequest
|
||||
|
@ -18,12 +20,11 @@ import com.google.android.gms.cast.framework.CastState
|
|||
import com.google.android.gms.common.ConnectionResult
|
||||
import com.google.android.gms.common.GoogleApiAvailability
|
||||
import com.google.android.gms.common.wrappers.Wrappers
|
||||
import com.lagradost.cloudstream3.MainAPI
|
||||
import com.lagradost.cloudstream3.R
|
||||
import com.lagradost.cloudstream3.SearchResponse
|
||||
import com.lagradost.cloudstream3.TvType
|
||||
import com.lagradost.cloudstream3.*
|
||||
import com.lagradost.cloudstream3.ui.result.ResultFragment
|
||||
import com.lagradost.cloudstream3.utils.UIHelper.navigate
|
||||
import java.net.URL
|
||||
import java.net.URLDecoder
|
||||
|
||||
object AppUtils {
|
||||
fun getVideoContentUri(context: Context, videoFilePath: String): Uri? {
|
||||
|
@ -44,6 +45,29 @@ object AppUtils {
|
|||
}
|
||||
}
|
||||
|
||||
fun Context.openBrowser(url: String) {
|
||||
val components = arrayOf(ComponentName(applicationContext, MainActivity::class.java))
|
||||
val intent = Intent(Intent.ACTION_VIEW)
|
||||
intent.data = Uri.parse(url)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N)
|
||||
startActivity(Intent.createChooser(intent, null).putExtra(Intent.EXTRA_EXCLUDE_COMPONENTS, components))
|
||||
else
|
||||
startActivity(intent)
|
||||
}
|
||||
|
||||
fun splitQuery(url: URL): Map<String, String> {
|
||||
val queryPairs: MutableMap<String, String> = LinkedHashMap()
|
||||
val query: String = url.query
|
||||
val pairs = query.split("&").toTypedArray()
|
||||
for (pair in pairs) {
|
||||
val idx = pair.indexOf("=")
|
||||
queryPairs[URLDecoder.decode(pair.substring(0, idx), "UTF-8")] =
|
||||
URLDecoder.decode(pair.substring(idx + 1), "UTF-8")
|
||||
}
|
||||
return queryPairs
|
||||
}
|
||||
|
||||
/**| S1:E2 Hello World
|
||||
* | Episode 2. Hello world
|
||||
* | Hello World
|
||||
|
@ -82,7 +106,10 @@ object AppUtils {
|
|||
fun AppCompatActivity.loadResult(url: String, apiName: String, startAction: Int = 0, startValue: Int = 0) {
|
||||
this.runOnUiThread {
|
||||
// viewModelStore.clear()
|
||||
this.navigate(R.id.global_to_navigation_results, ResultFragment.newInstance(url, apiName, startAction, startValue))
|
||||
this.navigate(
|
||||
R.id.global_to_navigation_results,
|
||||
ResultFragment.newInstance(url, apiName, startAction, startValue)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
@ -2,6 +2,7 @@ package com.lagradost.cloudstream3.utils
|
|||
|
||||
import android.os.Handler
|
||||
import android.os.Looper
|
||||
import com.lagradost.cloudstream3.mvvm.logError
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
|
@ -13,6 +14,17 @@ object Coroutines {
|
|||
work()
|
||||
}
|
||||
}
|
||||
|
||||
fun ioSafe(work: suspend (() -> Unit)) : Job {
|
||||
return CoroutineScope(Dispatchers.IO).launch {
|
||||
try {
|
||||
work()
|
||||
} catch (e : Exception) {
|
||||
logError(e)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun runOnMainThread(work: (() -> Unit)) {
|
||||
val mainHandler = Handler(Looper.getMainLooper())
|
||||
mainHandler.post {
|
||||
|
|
16
app/src/main/res/drawable/ic_anilist_icon.xml
Normal file
16
app/src/main/res/drawable/ic_anilist_icon.xml
Normal file
|
@ -0,0 +1,16 @@
|
|||
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:width="24dp"
|
||||
android:height="24dp"
|
||||
android:viewportWidth="172"
|
||||
android:viewportHeight="172"
|
||||
android:tint="?attr/white"
|
||||
>
|
||||
<path
|
||||
android:pathData="M111.322,111.157L111.322,41.029C111.322,37.01 109.105,34.792 105.086,34.792L91.365,34.792C87.346,34.792 85.128,37.01 85.128,41.029C85.128,41.029 85.128,56.337 85.128,74.333C85.128,75.271 94.165,79.626 94.401,80.547C101.286,107.449 95.897,128.98 89.37,129.985C100.042,130.513 101.216,135.644 93.267,132.138C94.483,117.784 99.228,117.812 112.869,131.61C112.986,131.729 115.666,137.351 115.833,137.351C131.17,137.351 148.05,137.351 148.05,137.351C152.069,137.351 154.286,135.134 154.286,131.115L154.286,117.394C154.286,113.375 152.069,111.157 148.05,111.157L111.322,111.157Z"
|
||||
android:fillColor="@color/black"
|
||||
android:fillType="evenOdd"/>
|
||||
<path
|
||||
android:pathData="M54.365,34.792L18.331,137.351L46.327,137.351L52.425,119.611L82.915,119.611L88.875,137.351L116.732,137.351L80.836,34.792L54.365,34.792ZM58.8,96.882L67.531,68.47L77.094,96.882L58.8,96.882Z"
|
||||
android:fillColor="@color/white"
|
||||
android:fillType="evenOdd"/>
|
||||
</vector>
|
15
app/src/main/res/drawable/mal_logo.xml
Normal file
15
app/src/main/res/drawable/mal_logo.xml
Normal file
|
@ -0,0 +1,15 @@
|
|||
<vector
|
||||
xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
android:name="vector"
|
||||
android:width="30dp"
|
||||
android:height="30dp"
|
||||
android:viewportWidth="850"
|
||||
android:viewportHeight="850"
|
||||
android:tint="?attr/white"
|
||||
>
|
||||
<path
|
||||
android:name="path"
|
||||
android:pathData="M 402 556 C 401.57 555.28 401 554.28 400.25 553 C 387.65 531.12 334.6 431 378.5 352.25 C 385.214 340.432 393.638 329.673 403.5 320.32 C 423 303 449 293.09 460.38 290.67 L 554.88 290.67 L 570 342.59 L 474 342.59 C 439.5 353.17 427.17 389 428 395.8 L 494.67 395.8 L 494.67 349 L 560.33 349 L 560.33 522.67 L 494.67 522.67 L 494.67 445.67 L 423 445.67 C 422.007 448.508 421.834 451.568 422.5 454.5 C 427.77 479.68 441.74 506.43 450 521 Z M 606.67 523.33 L 606.67 290.67 L 669.33 290.67 L 669.33 471.33 L 750.67 471.33 L 738 523.33 L 606.67 523.33 Z M 268.5 523.33 L 268.5 384 L 214 445.5 L 163.5 384 L 163.5 523.33 L 102.5 523.33 L 102.5 290.67 L 165.5 290.67 L 213.5 362.5 L 266.5 290.67 L 329 290.67 L 329 523.33 L 268.5 523.33 Z"
|
||||
android:fillColor="#ffffff"
|
||||
android:strokeWidth="1"/>
|
||||
</vector>
|
65
app/src/main/res/layout/account_managment.xml
Normal file
65
app/src/main/res/layout/account_managment.xml
Normal file
|
@ -0,0 +1,65 @@
|
|||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<LinearLayout
|
||||
xmlns:android="http://schemas.android.com/apk/res/android" xmlns:tools="http://schemas.android.com/tools"
|
||||
xmlns:app="http://schemas.android.com/apk/res-auto"
|
||||
android:orientation="vertical"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
|
||||
<LinearLayout
|
||||
android:paddingStart="20dp"
|
||||
android:paddingEnd="20dp"
|
||||
android:paddingTop="20dp"
|
||||
android:paddingBottom="10dp"
|
||||
android:orientation="horizontal"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content">
|
||||
<androidx.cardview.widget.CardView
|
||||
app:cardCornerRadius="100dp"
|
||||
android:layout_gravity="center_vertical"
|
||||
android:layout_width="35dp"
|
||||
android:layout_height="35dp">
|
||||
<ImageView
|
||||
android:id="@+id/account_profile_picture"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
tools:ignore="ContentDescription">
|
||||
</ImageView>
|
||||
</androidx.cardview.widget.CardView>
|
||||
<LinearLayout
|
||||
android:paddingStart="20dp"
|
||||
android:paddingEnd="20dp"
|
||||
android:layout_gravity="center_vertical"
|
||||
android:orientation="vertical"
|
||||
android:layout_width="match_parent" android:layout_height="wrap_content">
|
||||
<TextView
|
||||
android:id="@+id/account_name"
|
||||
|
||||
android:textStyle="bold"
|
||||
tools:text="MAL HelloWorld"
|
||||
android:textSize="20sp"
|
||||
android:textColor="?attr/textColor"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_rowWeight="1"
|
||||
android:layout_height="wrap_content">
|
||||
</TextView>
|
||||
<TextView
|
||||
android:id="@+id/account_site"
|
||||
android:layout_gravity="center_vertical"
|
||||
tools:text="MyAnimeList"
|
||||
android:textSize="15sp"
|
||||
android:textColor="?attr/grayTextColor"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_rowWeight="1"
|
||||
android:layout_height="wrap_content">
|
||||
</TextView>
|
||||
</LinearLayout>
|
||||
|
||||
</LinearLayout>
|
||||
<TextView
|
||||
android:id="@+id/account_logout"
|
||||
android:text="@string/logout"
|
||||
style="@style/SettingsItem">
|
||||
<requestFocus/>
|
||||
</TextView>
|
||||
</LinearLayout>
|
|
@ -105,16 +105,35 @@
|
|||
android:layout_width="match_parent"
|
||||
android:layout_height="70dp">
|
||||
<LinearLayout
|
||||
android:gravity="center"
|
||||
|
||||
android:layout_marginEnd="50dp"
|
||||
android:paddingTop="10dp"
|
||||
android:paddingBottom="10dp"
|
||||
|
||||
android:paddingStart="10dp"
|
||||
android:paddingEnd="10dp"
|
||||
|
||||
android:orientation="horizontal"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent">
|
||||
<androidx.cardview.widget.CardView
|
||||
android:id="@+id/home_profile_picture_holder"
|
||||
android:layout_marginEnd="20dp"
|
||||
app:cardCornerRadius="100dp"
|
||||
android:layout_gravity="center_vertical"
|
||||
android:layout_width="35dp"
|
||||
android:layout_height="35dp">
|
||||
<ImageView
|
||||
android:id="@+id/home_profile_picture"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
tools:ignore="ContentDescription">
|
||||
</ImageView>
|
||||
</androidx.cardview.widget.CardView>
|
||||
<LinearLayout
|
||||
android:gravity="center"
|
||||
|
||||
android:orientation="vertical"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_marginEnd="50dp"
|
||||
android:layout_height="match_parent">
|
||||
<TextView
|
||||
android:gravity="center_vertical"
|
||||
|
@ -138,6 +157,8 @@
|
|||
android:layout_height="wrap_content">
|
||||
</TextView>
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
|
||||
<ImageView
|
||||
android:nextFocusDown="@id/home_main_poster_recyclerview"
|
||||
|
|
|
@ -310,4 +310,22 @@
|
|||
|
||||
<string name="primary_color_settings">Primary Color</string>
|
||||
<string name="app_theme_settings">App Theme</string>
|
||||
|
||||
<!-- account stuff -->
|
||||
<string name="anilist_key" translatable="false">anilist_key</string>
|
||||
<string name="mal_key" translatable="false">mal_key</string>
|
||||
<!--
|
||||
<string name="mal_account_settings" translatable="false">MAL</string>
|
||||
<string name="anilist_account_settings" translatable="false">AniList</string>
|
||||
<string name="tmdb_account_settings" translatable="false">TMDB</string>
|
||||
<string name="imdb_account_settings" translatable="false">IMDB</string>
|
||||
<string name="kitsu_account_settings" translatable="false">Kitsu</string>
|
||||
<string name="trakt_account_settings" translatable="false">Trakt</string>
|
||||
-->
|
||||
<string name="login_format">%s %s</string>
|
||||
<string name="account">account</string>
|
||||
<string name="logout">Logout</string>
|
||||
<string name="login">Login</string>
|
||||
<!-- ============ -->
|
||||
|
||||
</resources>
|
||||
|
|
|
@ -177,6 +177,17 @@
|
|||
app:key="@string/manual_check_update_key"
|
||||
app:icon="@drawable/ic_baseline_system_update_24"
|
||||
/>
|
||||
|
||||
<Preference
|
||||
android:key="@string/mal_key"
|
||||
android:icon="@drawable/mal_logo">
|
||||
</Preference>
|
||||
|
||||
<Preference
|
||||
android:key="@string/anilist_key"
|
||||
android:icon="@drawable/ic_anilist_icon">
|
||||
</Preference>
|
||||
|
||||
<Preference
|
||||
android:title="@string/github"
|
||||
android:icon="@drawable/ic_github_logo"
|
||||
|
|
Loading…
Reference in a new issue