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="vidembed.cc" android:pathPrefix="/"/>
<data android:scheme="https" android:host="trailers.to" android:pathPrefix="/"/> <data android:scheme="https" android:host="trailers.to" android:pathPrefix="/"/>
</intent-filter> </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> </activity>
<receiver <receiver

View file

@ -28,6 +28,8 @@ import com.lagradost.cloudstream3.APIHolder.restrictedApis
import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.network.initRequestClient import com.lagradost.cloudstream3.network.initRequestClient
import com.lagradost.cloudstream3.receivers.VideoDownloadRestartReceiver 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.APIRepository
import com.lagradost.cloudstream3.ui.download.DOWNLOAD_NAVIGATE_TO import com.lagradost.cloudstream3.ui.download.DOWNLOAD_NAVIGATE_TO
import com.lagradost.cloudstream3.ui.player.PlayerEventType import com.lagradost.cloudstream3.ui.player.PlayerEventType
@ -347,13 +349,21 @@ class MainActivity : AppCompatActivity(), ColorPickerDialogListener {
if (intent == null) return if (intent == null) return
val str = intent.dataString val str = intent.dataString
if (str != null) { if (str != null) {
if (str.startsWith(DOWNLOAD_NAVIGATE_TO)) { if (str.contains(appString)) {
this.navigate(R.id.navigation_downloads) for (api in OAuth2Apis) {
if (str.contains("/${api.redirectUrl}")) {
api.handleRedirect(this, str)
}
}
} else { } else {
for (api in apis) { if (str.startsWith(DOWNLOAD_NAVIGATE_TO)) {
if (str.startsWith(api.mainUrl)) { this.navigate(R.id.navigation_downloads)
loadResult(str, api.name) } else {
break for (api in apis) {
if (str.startsWith(api.mainUrl)) {
loadResult(str, api.name)
break
}
} }
} }
} }

View file

@ -65,10 +65,12 @@ val Response.cookies: Map<String, String>
}?.filter { it.key.isNotBlank() && it.value.isNotBlank() } ?: mapOf() }?.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() val builder = FormBody.Builder()
data.forEach { data.forEach {
builder.add(it.key, it.value) it.value?.let { value ->
builder.add(it.key, value)
}
} }
return builder.build() return builder.build()
} }
@ -83,10 +85,12 @@ fun appendUri(uri: String, appendQuery: String): String {
} }
// Can probably be done recursively // 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 var appendedUrl = url
params.forEach { params.forEach {
appendedUrl = appendUri(appendedUrl, "${it.key}=${it.value}") it.value?.let { value ->
appendedUrl = appendUri(appendedUrl, "${it.key}=${value}")
}
} }
return appendedUrl return appendedUrl
} }
@ -189,3 +193,44 @@ fun postRequestCreator(
.post(getData(data)) .post(getData(data))
.build() .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 package com.lagradost.cloudstream3.syncproviders
import android.content.Context
//TODO dropbox sync //TODO dropbox sync
class Dropbox : OAuth2Interface { class Dropbox : OAuth2Interface {
override val name: String
get() = "Dropbox"
override val key: String override val key: String
get() = "zlqsamadlwydvb2" get() = "zlqsamadlwydvb2"
override val redirectUrl: String override val redirectUrl: String
get() = "dropboxlogin" 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 package com.lagradost.cloudstream3.syncproviders
import android.content.Context
import java.util.concurrent.TimeUnit
interface OAuth2Interface { interface OAuth2Interface {
val key : String val key : String
val name : String
val redirectUrl : 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.Resource
import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.mvvm.observe 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.noneApi
import com.lagradost.cloudstream3.ui.APIRepository.Companion.randomApi import com.lagradost.cloudstream3.ui.APIRepository.Companion.randomApi
import com.lagradost.cloudstream3.ui.AutofitRecyclerView 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.getGridIsCompact
import com.lagradost.cloudstream3.utils.UIHelper.popupMenuNoIcons import com.lagradost.cloudstream3.utils.UIHelper.popupMenuNoIcons
import com.lagradost.cloudstream3.utils.UIHelper.popupMenuNoIconsAndNoStringRes import com.lagradost.cloudstream3.utils.UIHelper.popupMenuNoIconsAndNoStringRes
import com.lagradost.cloudstream3.utils.UIHelper.setImage
import com.lagradost.cloudstream3.widget.CenterZoomLayoutManager import com.lagradost.cloudstream3.widget.CenterZoomLayoutManager
import kotlinx.android.synthetic.main.fragment_home.* import kotlinx.android.synthetic.main.fragment_home.*
@ -420,5 +422,19 @@ class HomeFragment : Fragment() {
//println("Caught home: " + homeViewModel.apiName.value + " at " + apiName) //println("Caught home: " + homeViewModel.apiName.value + " at " + apiName)
homeViewModel.loadAndCancel(apiName, currentPrefMedia) 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 package com.lagradost.cloudstream3.ui.settings
import android.content.Intent
import android.net.Uri
import android.app.UiModeManager import android.app.UiModeManager
import android.content.Context import android.content.Context
import android.content.Intent
import android.content.res.Configuration import android.content.res.Configuration
import android.net.Uri
import android.os.Bundle import android.os.Bundle
import android.os.Environment import android.os.Environment
import android.widget.ImageView
import android.widget.TextView
import android.widget.Toast import android.widget.Toast
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
import androidx.appcompat.app.AlertDialog 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.R
import com.lagradost.cloudstream3.mvvm.logError import com.lagradost.cloudstream3.mvvm.logError
import com.lagradost.cloudstream3.network.initRequestClient 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.APIRepository
import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment import com.lagradost.cloudstream3.ui.subtitles.SubtitlesFragment
import com.lagradost.cloudstream3.utils.AppUtils 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.SingleSelectionHelper.showMultiDialog
import com.lagradost.cloudstream3.utils.SubtitleHelper import com.lagradost.cloudstream3.utils.SubtitleHelper
import com.lagradost.cloudstream3.utils.UIHelper.hideKeyboard 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.getBasePath
import com.lagradost.cloudstream3.utils.VideoDownloadManager.getDownloadDir import com.lagradost.cloudstream3.utils.VideoDownloadManager.getDownloadDir
import com.lagradost.cloudstream3.utils.VideoDownloadManager.isScopedStorage import com.lagradost.cloudstream3.utils.VideoDownloadManager.isScopedStorage
@ -61,6 +67,8 @@ class SettingsFragment : PreferenceFragmentCompat() {
val uiModeManager = getSystemService(Context.UI_MODE_SERVICE) as UiModeManager? val uiModeManager = getSystemService(Context.UI_MODE_SERVICE) as UiModeManager?
return uiModeManager?.currentModeType == Configuration.UI_MODE_TYPE_TELEVISION return uiModeManager?.currentModeType == Configuration.UI_MODE_TYPE_TELEVISION
} }
private const val accountEnabled = false
} }
private var beneneCount = 0 private var beneneCount = 0
@ -111,6 +119,23 @@ class SettingsFragment : PreferenceFragmentCompat() {
Triple("🇹🇷", "Turkish", "tr") Triple("🇹🇷", "Turkish", "tr")
).sortedBy { it.second } //ye, we go alphabetical, so ppl don't put their lang on top ).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?) { override fun onCreatePreferences(savedInstanceState: Bundle?, rootKey: String?) {
hideKeyboard() hideKeyboard()
setPreferencesFromResource(R.xml.settings, rootKey) setPreferencesFromResource(R.xml.settings, rootKey)
@ -128,6 +153,27 @@ class SettingsFragment : PreferenceFragmentCompat() {
val preferedMediaTypePreference = findPreference<Preference>(getString(R.string.prefer_media_type_key))!! val preferedMediaTypePreference = findPreference<Preference>(getString(R.string.prefer_media_type_key))!!
val appThemePreference = findPreference<Preference>(getString(R.string.app_theme_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 { legalPreference.setOnPreferenceClickListener {
val builder: AlertDialog.Builder = AlertDialog.Builder(it.context) val builder: AlertDialog.Builder = AlertDialog.Builder(it.context)
builder.setTitle(R.string.legal_notice) builder.setTitle(R.string.legal_notice)
@ -206,7 +252,7 @@ class SettingsFragment : PreferenceFragmentCompat() {
return@setOnPreferenceClickListener true return@setOnPreferenceClickListener true
} }
fun getDownloadDirs(): List<String> { fun getDownloadDirs(): List<String> {
val defaultDir = getDownloadDir()?.filePath val defaultDir = getDownloadDir()?.filePath
@ -248,7 +294,7 @@ class SettingsFragment : PreferenceFragmentCompat() {
} }
return@setOnPreferenceClickListener true return@setOnPreferenceClickListener true
} }
preferedMediaTypePreference.setOnPreferenceClickListener { preferedMediaTypePreference.setOnPreferenceClickListener {
val prefNames = resources.getStringArray(R.array.media_type_pref) val prefNames = resources.getStringArray(R.array.media_type_pref)
val prefValues = resources.getIntArray(R.array.media_type_pref_values) val prefValues = resources.getIntArray(R.array.media_type_pref_values)

View file

@ -1,8 +1,10 @@
package com.lagradost.cloudstream3.utils package com.lagradost.cloudstream3.utils
import android.app.Activity import android.app.Activity
import android.content.ComponentName
import android.content.ContentValues import android.content.ContentValues
import android.content.Context import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.media.AudioAttributes import android.media.AudioAttributes
import android.media.AudioFocusRequest 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.ConnectionResult
import com.google.android.gms.common.GoogleApiAvailability import com.google.android.gms.common.GoogleApiAvailability
import com.google.android.gms.common.wrappers.Wrappers import com.google.android.gms.common.wrappers.Wrappers
import com.lagradost.cloudstream3.MainAPI import com.lagradost.cloudstream3.*
import com.lagradost.cloudstream3.R
import com.lagradost.cloudstream3.SearchResponse
import com.lagradost.cloudstream3.TvType
import com.lagradost.cloudstream3.ui.result.ResultFragment import com.lagradost.cloudstream3.ui.result.ResultFragment
import com.lagradost.cloudstream3.utils.UIHelper.navigate import com.lagradost.cloudstream3.utils.UIHelper.navigate
import java.net.URL
import java.net.URLDecoder
object AppUtils { object AppUtils {
fun getVideoContentUri(context: Context, videoFilePath: String): Uri? { 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 /**| S1:E2 Hello World
* | Episode 2. Hello world * | Episode 2. Hello world
* | Hello World * | Hello World
@ -81,8 +105,11 @@ object AppUtils {
fun AppCompatActivity.loadResult(url: String, apiName: String, startAction: Int = 0, startValue: Int = 0) { fun AppCompatActivity.loadResult(url: String, apiName: String, startAction: Int = 0, startValue: Int = 0) {
this.runOnUiThread { this.runOnUiThread {
// viewModelStore.clear() // 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)
)
} }
} }
@ -197,7 +224,7 @@ object AppUtils {
// Filter API depending on preferred media type // Filter API depending on preferred media type
val listEnumAnime = listOf(TvType.Anime, TvType.AnimeMovie, TvType.ONA) val listEnumAnime = listOf(TvType.Anime, TvType.AnimeMovie, TvType.ONA)
val listEnumMovieTv = listOf(TvType.Movie, TvType.TvSeries, TvType.Cartoon) val listEnumMovieTv = listOf(TvType.Movie, TvType.TvSeries, TvType.Cartoon)
val mediaTypeList = if (currentPrefMedia==1) listEnumMovieTv else listEnumAnime val mediaTypeList = if (currentPrefMedia == 1) listEnumMovieTv else listEnumAnime
val filteredAPI = allApis.filter { api -> api.supportedTypes.any { it in mediaTypeList } } val filteredAPI = allApis.filter { api -> api.supportedTypes.any { it in mediaTypeList } }
filteredAPI filteredAPI

View file

@ -2,6 +2,7 @@ package com.lagradost.cloudstream3.utils
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import com.lagradost.cloudstream3.mvvm.logError
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
@ -13,6 +14,17 @@ object Coroutines {
work() work()
} }
} }
fun ioSafe(work: suspend (() -> Unit)) : Job {
return CoroutineScope(Dispatchers.IO).launch {
try {
work()
} catch (e : Exception) {
logError(e)
}
}
}
fun runOnMainThread(work: (() -> Unit)) { fun runOnMainThread(work: (() -> Unit)) {
val mainHandler = Handler(Looper.getMainLooper()) val mainHandler = Handler(Looper.getMainLooper())
mainHandler.post { 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,40 +105,61 @@
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="70dp"> android:layout_height="70dp">
<LinearLayout <LinearLayout
android:gravity="center" android:layout_marginEnd="50dp"
android:paddingTop="10dp" android:paddingTop="10dp"
android:paddingBottom="10dp" android:paddingBottom="10dp"
android:paddingStart="10dp" android:paddingStart="10dp"
android:paddingEnd="10dp" android:paddingEnd="10dp"
android:orientation="vertical"
android:layout_width="match_parent"
android:layout_marginEnd="50dp"
android:layout_height="match_parent">
<TextView
android:gravity="center_vertical"
android:layout_gravity="center"
android:id="@+id/home_provider_name"
android:textColor="?attr/textColor"
android:textSize="20sp"
tools:text="Hello World"
android:layout_width="match_parent"
android:layout_height="wrap_content"> android:orientation="horizontal"
</TextView> android:layout_width="match_parent"
<TextView android:layout_height="match_parent">
android:gravity="center_vertical" <androidx.cardview.widget.CardView
android:layout_gravity="center" android:id="@+id/home_profile_picture_holder"
android:id="@+id/home_provider_meta_info" android:layout_marginEnd="20dp"
android:textColor="?attr/grayTextColor" app:cardCornerRadius="100dp"
android:textSize="14sp" android:layout_gravity="center_vertical"
tools:text="Hello World" 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_width="match_parent"
android:layout_height="wrap_content"> android:layout_height="match_parent">
</TextView> <TextView
android:gravity="center_vertical"
android:layout_gravity="center"
android:id="@+id/home_provider_name"
android:textColor="?attr/textColor"
android:textSize="20sp"
tools:text="Hello World"
android:layout_width="match_parent"
android:layout_height="wrap_content">
</TextView>
<TextView
android:gravity="center_vertical"
android:layout_gravity="center"
android:id="@+id/home_provider_meta_info"
android:textColor="?attr/grayTextColor"
android:textSize="14sp"
tools:text="Hello World"
android:layout_width="match_parent"
android:layout_height="wrap_content">
</TextView>
</LinearLayout>
</LinearLayout> </LinearLayout>
<ImageView <ImageView
android:nextFocusDown="@id/home_main_poster_recyclerview" android:nextFocusDown="@id/home_main_poster_recyclerview"
android:id="@+id/home_change_api" android:id="@+id/home_change_api"

View file

@ -310,4 +310,22 @@
<string name="primary_color_settings">Primary Color</string> <string name="primary_color_settings">Primary Color</string>
<string name="app_theme_settings">App Theme</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> </resources>

View file

@ -177,6 +177,17 @@
app:key="@string/manual_check_update_key" app:key="@string/manual_check_update_key"
app:icon="@drawable/ic_baseline_system_update_24" 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 <Preference
android:title="@string/github" android:title="@string/github"
android:icon="@drawable/ic_github_logo" android:icon="@drawable/ic_github_logo"