anilist/mal api, (NO UI YET)

This commit is contained in:
LagradOst 2021-11-07 23:10:19 +01:00
parent d323092f11
commit d6870836d9
17 changed files with 1845 additions and 48 deletions

View file

@ -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

View file

@ -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)

View file

@ -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()
}

View file

@ -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,
)
}

View file

@ -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")
}
}

View file

@ -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,
)
}

View file

@ -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"
}
}
}

View file

@ -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
}
}
}
}
}

View file

@ -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)

View file

@ -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)
)
}
}

View file

@ -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 {

View 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>

View 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>

View 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>

View file

@ -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"

View file

@ -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>

View file

@ -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"