Initial commit

This commit is contained in:
Finnley Somdahl 2023-10-17 18:42:43 -05:00
commit 21bfbfb139
520 changed files with 47819 additions and 0 deletions

View file

@ -0,0 +1,39 @@
package ani.dantotsu.connections
import ani.dantotsu.R
import ani.dantotsu.Refresh
import ani.dantotsu.connections.anilist.Anilist
import ani.dantotsu.connections.mal.MAL
import ani.dantotsu.currContext
import ani.dantotsu.media.Media
import ani.dantotsu.toast
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlin.math.roundToInt
fun updateProgress(media: Media, number: String) {
if (Anilist.userid != null) {
CoroutineScope(Dispatchers.IO).launch {
val a = number.toFloatOrNull()?.roundToInt()
if (a != media.userProgress) {
Anilist.mutation.editList(
media.id,
a,
status = if (media.userStatus == "REPEATING") media.userStatus else "CURRENT"
)
MAL.query.editList(
media.idMAL,
media.anime != null,
a, null,
if (media.userStatus == "REPEATING") media.userStatus!! else "CURRENT"
)
toast(currContext()?.getString(R.string.setting_progress, a))
}
media.userProgress = a
Refresh.all()
}
} else {
toast(currContext()?.getString(R.string.login_anilist_account))
}
}

View file

@ -0,0 +1,143 @@
package ani.dantotsu.connections.anilist
import android.content.ActivityNotFoundException
import android.content.Context
import android.net.Uri
import androidx.browser.customtabs.CustomTabsIntent
import ani.dantotsu.R
import ani.dantotsu.client
import ani.dantotsu.currContext
import ani.dantotsu.openLinkInBrowser
import ani.dantotsu.tryWithSuspend
import java.io.File
import java.util.*
object Anilist {
val query: AnilistQueries = AnilistQueries()
val mutation: AnilistMutations = AnilistMutations()
var token: String? = null
var username: String? = null
var adult: Boolean = false
var userid: Int? = null
var avatar: String? = null
var bg: String? = null
var episodesWatched: Int? = null
var chapterRead: Int? = null
var genres: ArrayList<String>? = null
var tags: Map<Boolean, List<String>>? = null
val sortBy = listOf(
"SCORE_DESC","POPULARITY_DESC","TRENDING_DESC","TITLE_ENGLISH","TITLE_ENGLISH_DESC","SCORE"
)
val seasons = listOf(
"WINTER", "SPRING", "SUMMER", "FALL"
)
val anime_formats = listOf(
"TV", "TV SHORT", "MOVIE", "SPECIAL", "OVA", "ONA", "MUSIC"
)
val manga_formats = listOf(
"MANGA", "NOVEL", "ONE SHOT"
)
val authorRoles = listOf(
"Original Creator", "Story & Art", "Story"
)
private val cal: Calendar = Calendar.getInstance()
private val currentYear = cal.get(Calendar.YEAR)
private val currentSeason: Int = when (cal.get(Calendar.MONTH)) {
0, 1, 2 -> 0
3, 4, 5 -> 1
6, 7, 8 -> 2
9, 10, 11 -> 3
else -> 0
}
private fun getSeason(next: Boolean): Pair<String, Int> {
var newSeason = if (next) currentSeason + 1 else currentSeason - 1
var newYear = currentYear
if (newSeason > 3) {
newSeason = 0
newYear++
} else if (newSeason < 0) {
newSeason = 3
newYear--
}
return seasons[newSeason] to newYear
}
val currentSeasons = listOf(
getSeason(false),
seasons[currentSeason] to currentYear,
getSeason(true)
)
fun loginIntent(context: Context) {
val clientID = 14959
try {
CustomTabsIntent.Builder().build().launchUrl(
context,
Uri.parse("https://anilist.co/api/v2/oauth/authorize?client_id=$clientID&response_type=token")
)
} catch (e: ActivityNotFoundException) {
openLinkInBrowser("https://anilist.co/api/v2/oauth/authorize?client_id=$clientID&response_type=token")
}
}
fun getSavedToken(context: Context): Boolean {
if ("anilistToken" in context.fileList()) {
token = File(context.filesDir, "anilistToken").readText()
return true
}
return false
}
fun removeSavedToken(context: Context) {
token = null
username = null
adult = false
userid = null
avatar = null
bg = null
episodesWatched = null
chapterRead = null
if ("anilistToken" in context.fileList()) {
File(context.filesDir, "anilistToken").delete()
}
}
suspend inline fun <reified T : Any> executeQuery(
query: String,
variables: String = "",
force: Boolean = false,
useToken: Boolean = true,
show: Boolean = false,
cache: Int? = null
): T? {
return tryWithSuspend {
val data = mapOf(
"query" to query,
"variables" to variables
)
val headers = mutableMapOf(
"Content-Type" to "application/json",
"Accept" to "application/json"
)
if (token != null || force) {
if (token != null && useToken) headers["Authorization"] = "Bearer $token"
val json = client.post("https://graphql.anilist.co/", headers, data = data, cacheTime = cache ?: 10)
if (!json.text.startsWith("{")) throw Exception(currContext()?.getString(R.string.anilist_down))
if (show) println("Response : ${json.text}")
json.parsed()
} else null
}
}
}

View file

@ -0,0 +1,55 @@
package ani.dantotsu.connections.anilist
import ani.dantotsu.connections.anilist.Anilist.executeQuery
import ani.dantotsu.connections.anilist.api.FuzzyDate
import kotlinx.serialization.json.JsonObject
class AnilistMutations {
suspend fun toggleFav(anime: Boolean = true, id: Int) {
val query =
"""mutation (${"$"}animeId: Int,${"$"}mangaId:Int) { ToggleFavourite(animeId:${"$"}animeId,mangaId:${"$"}mangaId){ anime { edges { id } } manga { edges { id } } } }"""
val variables = if (anime) """{"animeId":"$id"}""" else """{"mangaId":"$id"}"""
executeQuery<JsonObject>(query, variables)
}
suspend fun editList(
mediaID: Int,
progress: Int? = null,
score: Int? = null,
repeat: Int? = null,
notes: String? = null,
status: String? = null,
private:Boolean? = null,
startedAt: FuzzyDate? = null,
completedAt: FuzzyDate? = null,
customList: List<String>? = null
) {
val query = """
mutation ( ${"$"}mediaID: Int, ${"$"}progress: Int,${"$"}private:Boolean,${"$"}repeat: Int, ${"$"}notes: String, ${"$"}customLists: [String], ${"$"}scoreRaw:Int, ${"$"}status:MediaListStatus, ${"$"}start:FuzzyDateInput${if (startedAt != null) "=" + startedAt.toVariableString() else ""}, ${"$"}completed:FuzzyDateInput${if (completedAt != null) "=" + completedAt.toVariableString() else ""} ) {
SaveMediaListEntry( mediaId: ${"$"}mediaID, progress: ${"$"}progress, repeat: ${"$"}repeat, notes: ${"$"}notes, private: ${"$"}private, scoreRaw: ${"$"}scoreRaw, status:${"$"}status, startedAt: ${"$"}start, completedAt: ${"$"}completed , customLists: ${"$"}customLists ) {
score(format:POINT_10_DECIMAL) startedAt{year month day} completedAt{year month day}
}
}
""".replace("\n", "").replace(""" """, "")
val variables = """{"mediaID":$mediaID
${if (private != null) ""","private":$private""" else ""}
${if (progress != null) ""","progress":$progress""" else ""}
${if (score != null) ""","scoreRaw":$score""" else ""}
${if (repeat != null) ""","repeat":$repeat""" else ""}
${if (notes != null) ""","notes":"${notes.replace("\n", "\\n")}"""" else ""}
${if (status != null) ""","status":"$status"""" else ""}
${if (customList !=null) ""","customLists":[${customList.joinToString { "\"$it\"" }}]""" else ""}
}""".replace("\n", "").replace(""" """, "")
println(variables)
executeQuery<JsonObject>(query, variables, show = true)
}
suspend fun deleteList(listId: Int) {
val query = "mutation(${"$"}id:Int){DeleteMediaListEntry(id:${"$"}id){deleted}}"
val variables = """{"id":"$listId"}"""
executeQuery<JsonObject>(query, variables)
}
}

View file

@ -0,0 +1,925 @@
package ani.dantotsu.connections.anilist
import android.app.Activity
import ani.dantotsu.R
import ani.dantotsu.connections.anilist.Anilist.authorRoles
import ani.dantotsu.connections.anilist.Anilist.executeQuery
import ani.dantotsu.connections.anilist.api.FuzzyDate
import ani.dantotsu.connections.anilist.api.Page
import ani.dantotsu.connections.anilist.api.Query
import ani.dantotsu.checkGenreTime
import ani.dantotsu.checkId
import ani.dantotsu.currContext
import ani.dantotsu.loadData
import ani.dantotsu.logError
import ani.dantotsu.media.Author
import ani.dantotsu.media.Character
import ani.dantotsu.media.Media
import ani.dantotsu.media.Studio
import ani.dantotsu.others.MalScraper
import ani.dantotsu.saveData
import ani.dantotsu.snackString
import kotlinx.coroutines.async
import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.runBlocking
import kotlin.system.measureTimeMillis
class AnilistQueries {
suspend fun getUserData(): Boolean {
val response: Query.Viewer?
measureTimeMillis {
response =
executeQuery("""{Viewer{name options{displayAdultContent}avatar{medium}bannerImage id mediaListOptions{rowOrder animeList{sectionOrder customLists}mangaList{sectionOrder customLists}}statistics{anime{episodesWatched}manga{chaptersRead}}}}""")
}.also { println("time : $it") }
val user = response?.data?.user ?: return false
Anilist.userid = user.id
Anilist.username = user.name
Anilist.bg = user.bannerImage
Anilist.avatar = user.avatar?.medium
Anilist.episodesWatched = user.statistics?.anime?.episodesWatched
Anilist.chapterRead = user.statistics?.manga?.chaptersRead
Anilist.adult = user.options?.displayAdultContent ?: false
return true
}
suspend fun getMedia(id: Int, mal: Boolean = false): Media? {
val response = executeQuery<Query.Media>(
"""{Media(${if (!mal) "id:" else "idMal:"}$id){id idMal status chapters episodes nextAiringEpisode{episode}type meanScore isAdult isFavourite format bannerImage coverImage{large}title{english romaji userPreferred}mediaListEntry{progress private score(format:POINT_100)status}}}""",
force = true
)
val fetchedMedia = response?.data?.media ?: return null
return Media(fetchedMedia)
}
fun mediaDetails(media: Media): Media {
media.cameFromContinue = false
val query =
"""{Media(id:${media.id}){id mediaListEntry{id status score(format:POINT_100) progress private notes repeat customLists updatedAt startedAt{year month day}completedAt{year month day}}isFavourite siteUrl idMal nextAiringEpisode{episode airingAt}source countryOfOrigin format duration season seasonYear startDate{year month day}endDate{year month day}genres studios(isMain:true){nodes{id name siteUrl}}description trailer { site id } synonyms tags { name rank isMediaSpoiler } characters(sort:[ROLE,FAVOURITES_DESC],perPage:25,page:1){edges{role node{id image{medium}name{userPreferred}}}}relations{edges{relationType(version:2)node{id idMal mediaListEntry{progress private score(format:POINT_100) status} episodes chapters nextAiringEpisode{episode} popularity meanScore isAdult isFavourite format title{english romaji userPreferred}type status(version:2)bannerImage coverImage{large}}}}staffPreview: staff(perPage: 8, sort: [RELEVANCE, ID]) {edges{role node{id name{userPreferred}}}}recommendations(sort:RATING_DESC){nodes{mediaRecommendation{id idMal mediaListEntry{progress private score(format:POINT_100) status} episodes chapters nextAiringEpisode{episode}meanScore isAdult isFavourite format title{english romaji userPreferred}type status(version:2)bannerImage coverImage{large}}}}externalLinks{url site}}}"""
runBlocking {
val anilist = async {
var response = executeQuery<Query.Media>(query, force = true, show = true)
if (response != null) {
fun parse() {
val fetchedMedia = response?.data?.media ?: return
media.source = fetchedMedia.source?.toString()
media.countryOfOrigin = fetchedMedia.countryOfOrigin
media.format = fetchedMedia.format?.toString()
media.startDate = fetchedMedia.startDate
media.endDate = fetchedMedia.endDate
if (fetchedMedia.genres != null) {
media.genres = arrayListOf()
fetchedMedia.genres?.forEach { i ->
media.genres.add(i)
}
}
media.trailer = fetchedMedia.trailer?.let { i ->
if (i.site != null && i.site.toString() == "youtube")
"https://www.youtube.com/embed/${i.id.toString().trim('"')}"
else null
}
fetchedMedia.synonyms?.apply {
media.synonyms = arrayListOf()
this.forEach { i ->
media.synonyms.add(
i
)
}
}
fetchedMedia.tags?.apply {
media.tags = arrayListOf()
this.forEach { i ->
if (i.isMediaSpoiler == false)
media.tags.add("${i.name} : ${i.rank.toString()}%")
}
}
media.description = fetchedMedia.description.toString()
if (fetchedMedia.characters != null) {
media.characters = arrayListOf()
fetchedMedia.characters?.edges?.forEach { i ->
i.node?.apply {
media.characters?.add(
Character(
id = id,
name = i.node?.name?.userPreferred,
image = i.node?.image?.medium,
banner = media.banner ?: media.cover,
role = when (i.role.toString()){
"MAIN" -> currContext()?.getString(R.string.main_role) ?: "MAIN"
"SUPPORTING" -> currContext()?.getString(R.string.supporting_role) ?: "SUPPORTING"
else -> i.role.toString()
}
)
)
}
}
}
if (fetchedMedia.relations != null) {
media.relations = arrayListOf()
fetchedMedia.relations?.edges?.forEach { mediaEdge ->
val m = Media(mediaEdge)
media.relations?.add(m)
if (m.relation == "SEQUEL") {
media.sequel = if ((media.sequel?.popularity ?: 0) < (m.popularity ?: 0)) m else media.sequel
} else if (m.relation == "PREQUEL") {
media.prequel =
if ((media.prequel?.popularity ?: 0) < (m.popularity ?: 0)) m else media.prequel
}
}
media.relations?.sortByDescending { it.popularity }
media.relations?.sortByDescending { it.startDate?.year }
media.relations?.sortBy { it.relation }
}
if (fetchedMedia.recommendations != null) {
media.recommendations = arrayListOf()
fetchedMedia.recommendations?.nodes?.forEach { i ->
i.mediaRecommendation?.apply {
media.recommendations?.add(
Media(this)
)
}
}
}
if (fetchedMedia.mediaListEntry != null) {
fetchedMedia.mediaListEntry?.apply {
media.userProgress = progress
media.isListPrivate = private ?: false
media.notes = notes
media.userListId = id
media.userScore = score?.toInt() ?: 0
media.userStatus = status?.toString()
media.inCustomListsOf = customLists?.toMutableMap()
media.userRepeat = repeat ?: 0
media.userUpdatedAt = updatedAt?.toString()?.toLong()?.times(1000)
media.userCompletedAt = completedAt ?: FuzzyDate()
media.userStartedAt = startedAt ?: FuzzyDate()
}
} else {
media.isListPrivate = false
media.userStatus = null
media.userListId = null
media.userProgress = null
media.userScore = 0
media.userRepeat = 0
media.userUpdatedAt = null
media.userCompletedAt = FuzzyDate()
media.userStartedAt = FuzzyDate()
}
if (media.anime != null) {
media.anime.episodeDuration = fetchedMedia.duration
media.anime.season = fetchedMedia.season?.toString()
media.anime.seasonYear = fetchedMedia.seasonYear
fetchedMedia.studios?.nodes?.apply {
if (isNotEmpty()) {
val firstStudio = get(0)
media.anime.mainStudio = Studio(
firstStudio.id.toString(),
firstStudio.name ?: "N/A"
)
}
}
fetchedMedia.staff?.edges?.find { authorRoles.contains(it.role?.trim()) }?.node?.let {
media.anime.author = Author(
it.id.toString(),
it.name?.userPreferred ?: "N/A"
)
}
media.anime.nextAiringEpisodeTime = fetchedMedia.nextAiringEpisode?.airingAt?.toLong()
fetchedMedia.externalLinks?.forEach { i ->
when (i.site.lowercase()) {
"youtube" -> media.anime.youtube = i.url
"crunchyroll" -> media.crunchySlug = i.url?.split("/")?.getOrNull(3)
"vrv" -> media.vrvId = i.url?.split("/")?.getOrNull(4)
}
}
}
else if (media.manga != null) {
fetchedMedia.staff?.edges?.find { authorRoles.contains(it.role?.trim()) }?.node?.let {
media.manga.author = Author(
it.id.toString(),
it.name?.userPreferred ?: "N/A"
)
}
}
media.shareLink = fetchedMedia.siteUrl
}
if (response.data?.media != null) parse()
else {
snackString(currContext()?.getString(R.string.adult_stuff))
response = executeQuery(query, force = true, useToken = false)
if (response?.data?.media != null) parse()
else snackString(currContext()?.getString(R.string.what_did_you_open))
}
} else {
snackString(currContext()?.getString(R.string.error_getting_data))
}
}
val mal = async {
if (media.idMAL != null) {
MalScraper.loadMedia(media)
}
}
awaitAll(anilist, mal)
}
return media
}
suspend fun continueMedia(type: String,planned:Boolean=false): ArrayList<Media> {
val returnArray = arrayListOf<Media>()
val map = mutableMapOf<Int, Media>()
val statuses = if(!planned) arrayOf("CURRENT", "REPEATING") else arrayOf("PLANNING")
suspend fun repeat(status: String) {
val response =
executeQuery<Query.MediaListCollection>(""" { MediaListCollection(userId: ${Anilist.userid}, type: $type, status: $status , sort: UPDATED_TIME ) { lists { entries { progress private score(format:POINT_100) status media { id idMal type isAdult status chapters episodes nextAiringEpisode {episode} meanScore isFavourite format bannerImage coverImage{large} title { english romaji userPreferred } } } } } } """)
response?.data?.mediaListCollection?.lists?.forEach { li ->
li.entries?.reversed()?.forEach {
val m = Media(it)
m.cameFromContinue = true
map[m.id] = m
}
}
}
statuses.forEach { repeat(it) }
val set = loadData<MutableSet<Int>>("continue_$type")
if (set != null) {
set.reversed().forEach {
if (map.containsKey(it)) returnArray.add(map[it]!!)
}
for (i in map) {
if (i.value !in returnArray) returnArray.add(i.value)
}
} else returnArray.addAll(map.values)
return returnArray
}
suspend fun favMedia(anime: Boolean): ArrayList<Media> {
var hasNextPage = true
var page = 0
suspend fun getNextPage(page:Int): List<Media> {
val response =
executeQuery<Query.User>("""{User(id:${Anilist.userid}){id favourites{${if (anime) "anime" else "manga"}(page:$page){pageInfo{hasNextPage}edges{favouriteOrder node{id idMal isAdult mediaListEntry{ progress private score(format:POINT_100) status } chapters isFavourite format episodes nextAiringEpisode{episode}meanScore isFavourite format startDate{year month day} title{english romaji userPreferred}type status(version:2)bannerImage coverImage{large}}}}}}}""")
val favourites = response?.data?.user?.favourites
val apiMediaList = if (anime) favourites?.anime else favourites?.manga
hasNextPage = apiMediaList?.pageInfo?.hasNextPage ?: false
return apiMediaList?.edges?.mapNotNull {
it.node?.let { i->
Media(i).apply { isFav = true }
}
} ?: return listOf()
}
val responseArray = arrayListOf<Media>()
while(hasNextPage){
page++
responseArray.addAll(getNextPage(page))
}
return responseArray
}
suspend fun recommendations(): ArrayList<Media> {
val response =
executeQuery<Query.Page>(""" { Page(page: 1, perPage:30) { pageInfo { total currentPage hasNextPage } recommendations(sort: RATING_DESC, onList: true) { rating userRating mediaRecommendation { id idMal isAdult mediaListEntry { progress private score(format:POINT_100) status } chapters isFavourite format episodes nextAiringEpisode {episode} popularity meanScore isFavourite format title {english romaji userPreferred } type status(version: 2) bannerImage coverImage { large } } } } } """)
val map = mutableMapOf<Int, Media>()
response?.data?.page?.apply {
recommendations?.onEach {
val json = it.mediaRecommendation
if (json != null) {
val m = Media(json)
m.relation = json.type?.toString()
map[m.id] = m
}
}
}
val types = arrayOf("ANIME", "MANGA")
suspend fun repeat(type: String) {
val res =
executeQuery<Query.MediaListCollection>(""" { MediaListCollection(userId: ${Anilist.userid}, type: $type, status: PLANNING , sort: MEDIA_POPULARITY_DESC ) { lists { entries { media { id mediaListEntry { progress private score(format:POINT_100) status } idMal type isAdult popularity status(version: 2) chapters episodes nextAiringEpisode {episode} meanScore isFavourite format bannerImage coverImage{large} title { english romaji userPreferred } } } } } } """)
res?.data?.mediaListCollection?.lists?.forEach { li ->
li.entries?.forEach {
val m = Media(it)
if (m.status == "RELEASING" || m.status == "FINISHED") {
m.relation = it.media?.type?.toString()
map[m.id] = m
}
}
}
}
types.forEach { repeat(it) }
val list = ArrayList(map.values.toList())
list.sortByDescending { it.meanScore }
return list
}
private suspend fun bannerImage(type: String): String? {
var image = loadData<BannerImage>("banner_$type")
if (image == null || image.checkTime()) {
val response =
executeQuery<Query.MediaListCollection>("""{ MediaListCollection(userId: ${Anilist.userid}, type: $type, chunk:1,perChunk:25, sort: [SCORE_DESC,UPDATED_TIME_DESC]) { lists { entries{ media { id bannerImage } } } } } """)
val random = response?.data?.mediaListCollection?.lists?.mapNotNull {
it.entries?.mapNotNull { entry ->
val imageUrl = entry.media?.bannerImage
if (imageUrl != null && imageUrl != "null") imageUrl
else null
}
}?.flatten()?.randomOrNull() ?: return null
image = BannerImage(
random,
System.currentTimeMillis()
)
saveData("banner_$type", image)
return image.url
} else return image.url
}
suspend fun getBannerImages(): ArrayList<String?> {
val default = arrayListOf<String?>(null, null)
default[0] = bannerImage("ANIME")
default[1] = bannerImage("MANGA")
return default
}
suspend fun getMediaLists(anime: Boolean, userId: Int, sortOrder: String? = null): MutableMap<String, ArrayList<Media>> {
val response =
executeQuery<Query.MediaListCollection>("""{ MediaListCollection(userId: $userId, type: ${if (anime) "ANIME" else "MANGA"}) { lists { name isCustomList entries { status progress private score(format:POINT_100) updatedAt media { id idMal isAdult type status chapters episodes nextAiringEpisode {episode} bannerImage meanScore isFavourite format coverImage{large} startDate{year month day} title {english romaji userPreferred } } } } user { id mediaListOptions { rowOrder animeList { sectionOrder } mangaList { sectionOrder } } } } }""")
val sorted = mutableMapOf<String, ArrayList<Media>>()
val unsorted = mutableMapOf<String, ArrayList<Media>>()
val all = arrayListOf<Media>()
val allIds = arrayListOf<Int>()
response?.data?.mediaListCollection?.lists?.forEach { i ->
val name = i.name.toString().trim('"')
unsorted[name] = arrayListOf()
i.entries?.forEach {
val a = Media(it)
unsorted[name]?.add(a)
if (!allIds.contains(a.id)) {
allIds.add(a.id)
all.add(a)
}
}
}
val options = response?.data?.mediaListCollection?.user?.mediaListOptions
val mediaList = if (anime) options?.animeList else options?.mangaList
mediaList?.sectionOrder?.forEach {
if (unsorted.containsKey(it)) sorted[it] = unsorted[it]!!
}
unsorted.forEach {
if(!sorted.containsKey(it.key)) sorted[it.key] = it.value
}
sorted["Favourites"] = favMedia(anime)
sorted["Favourites"]?.sortWith(compareBy { it.userFavOrder })
sorted["All"] = all
val sort = sortOrder ?: options?.rowOrder
for (i in sorted.keys) {
when (sort) {
"score" -> sorted[i]?.sortWith { b, a -> compareValuesBy(a, b, { it.userScore }, { it.meanScore }) }
"title" -> sorted[i]?.sortWith(compareBy { it.userPreferredName })
"updatedAt" -> sorted[i]?.sortWith(compareByDescending { it.userUpdatedAt })
"release" -> sorted[i]?.sortWith(compareByDescending { it.startDate })
"id" -> sorted[i]?.sortWith(compareBy { it.id })
}
}
return sorted
}
suspend fun getGenresAndTags(activity: Activity): Boolean {
var genres: ArrayList<String>? = loadData("genres_list", activity)
var tags: Map<Boolean, List<String>>? = loadData("tags_map", activity)
if (genres == null) {
executeQuery<Query.GenreCollection>(
"""{GenreCollection}""",
force = true,
useToken = false
)?.data?.genreCollection?.apply {
genres = arrayListOf()
forEach {
genres?.add(it)
}
saveData("genres_list", genres!!)
}
}
if (tags == null) {
executeQuery<Query.MediaTagCollection>(
"""{ MediaTagCollection { name isAdult } }""",
force = true
)?.data?.mediaTagCollection?.apply {
val adult = mutableListOf<String>()
val good = mutableListOf<String>()
forEach { node ->
if (node.isAdult == true) adult.add(node.name)
else good.add(node.name)
}
tags = mapOf(
true to adult,
false to good
)
saveData("tags_map", tags)
}
}
return if (genres != null && tags != null) {
Anilist.genres = genres
Anilist.tags = tags
true
} else false
}
suspend fun getGenres(genres: ArrayList<String>, listener: ((Pair<String, String>) -> Unit)) {
genres.forEach {
getGenreThumbnail(it).apply {
if (this != null) {
listener.invoke(it to this.thumbnail)
}
}
}
}
private suspend fun getGenreThumbnail(genre: String): Genre? {
val genres = loadData<MutableMap<String, Genre>>("genre_thumb") ?: mutableMapOf()
if (genres.checkGenreTime(genre)) {
try {
val genreQuery =
"""{ Page(perPage: 10){media(genre:"$genre", sort: TRENDING_DESC, type: ANIME, countryOfOrigin:"JP") {id bannerImage title{english romaji userPreferred} } } }"""
executeQuery<Query.Page>(genreQuery, force = true)?.data?.page?.media?.forEach {
if (genres.checkId(it.id) && it.bannerImage != null) {
genres[genre] = Genre(
genre,
it.id,
it.bannerImage!!,
System.currentTimeMillis()
)
saveData("genre_thumb", genres)
return genres[genre]
}
}
} catch (e: Exception) {
logError(e)
}
} else {
return genres[genre]
}
return null
}
suspend fun search(
type: String,
page: Int? = null,
perPage: Int? = null,
search: String? = null,
sort: String? = null,
genres: MutableList<String>? = null,
tags: MutableList<String>? = null,
format: String? = null,
isAdult: Boolean = false,
onList: Boolean? = null,
excludedGenres: MutableList<String>? = null,
excludedTags: MutableList<String>? = null,
seasonYear: Int? = null,
season: String? = null,
id: Int? = null,
hd: Boolean = false,
): SearchResults? {
val query = """
query (${"$"}page: Int = 1, ${"$"}id: Int, ${"$"}type: MediaType, ${"$"}isAdult: Boolean = false, ${"$"}search: String, ${"$"}format: [MediaFormat], ${"$"}status: MediaStatus, ${"$"}countryOfOrigin: CountryCode, ${"$"}source: MediaSource, ${"$"}season: MediaSeason, ${"$"}seasonYear: Int, ${"$"}year: String, ${"$"}onList: Boolean, ${"$"}yearLesser: FuzzyDateInt, ${"$"}yearGreater: FuzzyDateInt, ${"$"}episodeLesser: Int, ${"$"}episodeGreater: Int, ${"$"}durationLesser: Int, ${"$"}durationGreater: Int, ${"$"}chapterLesser: Int, ${"$"}chapterGreater: Int, ${"$"}volumeLesser: Int, ${"$"}volumeGreater: Int, ${"$"}licensedBy: [String], ${"$"}isLicensed: Boolean, ${"$"}genres: [String], ${"$"}excludedGenres: [String], ${"$"}tags: [String], ${"$"}excludedTags: [String], ${"$"}minimumTagRank: Int, ${"$"}sort: [MediaSort] = [POPULARITY_DESC, SCORE_DESC]) {
Page(page: ${"$"}page, perPage: ${perPage ?: 50}) {
pageInfo {
total
perPage
currentPage
lastPage
hasNextPage
}
media(id: ${"$"}id, type: ${"$"}type, season: ${"$"}season, format_in: ${"$"}format, status: ${"$"}status, countryOfOrigin: ${"$"}countryOfOrigin, source: ${"$"}source, search: ${"$"}search, onList: ${"$"}onList, seasonYear: ${"$"}seasonYear, startDate_like: ${"$"}year, startDate_lesser: ${"$"}yearLesser, startDate_greater: ${"$"}yearGreater, episodes_lesser: ${"$"}episodeLesser, episodes_greater: ${"$"}episodeGreater, duration_lesser: ${"$"}durationLesser, duration_greater: ${"$"}durationGreater, chapters_lesser: ${"$"}chapterLesser, chapters_greater: ${"$"}chapterGreater, volumes_lesser: ${"$"}volumeLesser, volumes_greater: ${"$"}volumeGreater, licensedBy_in: ${"$"}licensedBy, isLicensed: ${"$"}isLicensed, genre_in: ${"$"}genres, genre_not_in: ${"$"}excludedGenres, tag_in: ${"$"}tags, tag_not_in: ${"$"}excludedTags, minimumTagRank: ${"$"}minimumTagRank, sort: ${"$"}sort, isAdult: ${"$"}isAdult) {
id
idMal
isAdult
status
chapters
episodes
nextAiringEpisode {
episode
}
type
genres
meanScore
isFavourite
format
bannerImage
coverImage {
large
extraLarge
}
title {
english
romaji
userPreferred
}
mediaListEntry {
progress
private
score(format: POINT_100)
status
}
}
}
}
""".replace("\n", " ").replace(""" """, "")
val variables = """{"type":"$type","isAdult":$isAdult
${if (onList != null) ""","onList":$onList""" else ""}
${if (page != null) ""","page":"$page"""" else ""}
${if (id != null) ""","id":"$id"""" else ""}
${if (seasonYear != null) ""","seasonYear":"$seasonYear"""" else ""}
${if (season != null) ""","season":"$season"""" else ""}
${if (search != null) ""","search":"$search"""" else ""}
${if (sort!=null) ""","sort":"$sort"""" else ""}
${if (format != null) ""","format":"${format.replace(" ", "_")}"""" else ""}
${if (genres?.isNotEmpty() == true) ""","genres":[${genres.joinToString { "\"$it\"" }}]""" else ""}
${
if (excludedGenres?.isNotEmpty() == true)
""","excludedGenres":[${excludedGenres.joinToString { "\"${it.replace("Not ", "")}\"" }}]"""
else ""
}
${if (tags?.isNotEmpty() == true) ""","tags":[${tags.joinToString { "\"$it\"" }}]""" else ""}
${
if (excludedTags?.isNotEmpty() == true)
""","excludedTags":[${excludedTags.joinToString { "\"${it.replace("Not ", "")}\"" }}]"""
else ""
}
}""".replace("\n", " ").replace(""" """, "")
val response = executeQuery<Query.Page>(query, variables, true)?.data?.page
if (response?.media != null) {
val responseArray = arrayListOf<Media>()
response.media?.forEach { i ->
val userStatus = i.mediaListEntry?.status.toString()
val genresArr = arrayListOf<String>()
if (i.genres != null) {
i.genres?.forEach { genre ->
genresArr.add(genre)
}
}
val media = Media(i)
if (!hd) media.cover = i.coverImage?.large
media.relation = if (onList == true) userStatus else null
media.genres = genresArr
responseArray.add(media)
}
val pageInfo = response.pageInfo ?: return null
return SearchResults(
type = type,
perPage = perPage,
search = search,
sort = sort,
isAdult = isAdult,
onList = onList,
genres = genres,
excludedGenres = excludedGenres,
tags = tags,
excludedTags = excludedTags,
format = format,
seasonYear = seasonYear,
season = season,
results = responseArray,
page = pageInfo.currentPage.toString().toIntOrNull() ?: 0,
hasNextPage = pageInfo.hasNextPage == true,
)
} else snackString(currContext()?.getString(R.string.empty_response))
return null
}
suspend fun recentlyUpdated(
smaller: Boolean = true,
greater: Long = 0,
lesser: Long = System.currentTimeMillis() / 1000 - 10000
): MutableList<Media>? {
suspend fun execute(page:Int = 1):Page?{
val query = """{
Page(page:$page,perPage:50) {
pageInfo {
hasNextPage
total
}
airingSchedules(
airingAt_greater: $greater
airingAt_lesser: $lesser
sort:TIME_DESC
) {
episode
airingAt
media {
id
idMal
status
chapters
episodes
nextAiringEpisode { episode }
isAdult
type
meanScore
isFavourite
format
bannerImage
countryOfOrigin
coverImage { large }
title {
english
romaji
userPreferred
}
mediaListEntry {
progress
private
score(format: POINT_100)
status
}
}
}
}
}""".replace("\n", " ").replace(""" """, "")
return executeQuery<Query.Page>(query, force = true)?.data?.page
}
if(smaller) {
val response = execute()?.airingSchedules ?: return null
val idArr = mutableListOf<Int>()
val listOnly = loadData("recently_list_only") ?: false
return response.mapNotNull { i ->
i.media?.let {
if (!idArr.contains(it.id))
if (!listOnly && (it.countryOfOrigin == "JP" && (if (!Anilist.adult) it.isAdult == false else true)) || (listOnly && it.mediaListEntry != null)) {
idArr.add(it.id)
Media(it)
} else null
else null
}
}.toMutableList()
}else{
var i = 1
val list = mutableListOf<Media>()
var res : Page? = null
suspend fun next(){
res = execute(i)
list.addAll(res?.airingSchedules?.mapNotNull { j ->
j.media?.let {
if (it.countryOfOrigin == "JP" && (if (!Anilist.adult) it.isAdult == false else true)) {
Media(it).apply { relation = "${j.episode},${j.airingAt}" }
} else null
}
}?: listOf())
}
next()
while (res?.pageInfo?.hasNextPage == true){
next()
i++
}
return list.reversed().toMutableList()
}
}
suspend fun getCharacterDetails(character: Character): Character {
val query = """ {
Character(id: ${character.id}) {
id
age
gender
description
dateOfBirth {
year
month
day
}
media(page: 0,sort:[POPULARITY_DESC,SCORE_DESC]) {
pageInfo {
total
perPage
currentPage
lastPage
hasNextPage
}
edges {
id
characterRole
node {
id
idMal
isAdult
status
chapters
episodes
nextAiringEpisode { episode }
type
meanScore
isFavourite
format
bannerImage
countryOfOrigin
coverImage { large }
title {
english
romaji
userPreferred
}
mediaListEntry {
progress
private
score(format: POINT_100)
status
}
}
}
}
}
}""".replace("\n", " ").replace(""" """, "")
executeQuery<Query.Character>(query, force = true)?.data?.character?.apply {
character.age = age
character.gender = gender
character.description = description
character.dateOfBirth = dateOfBirth
character.roles = arrayListOf()
media?.edges?.forEach { i ->
val m = Media(i)
m.relation = i.characterRole.toString()
character.roles?.add(m)
}
}
return character
}
suspend fun getStudioDetails(studio: Studio): Studio {
fun query(page: Int = 0) = """ {
Studio(id: ${studio.id}) {
id
media(page: $page,sort:START_DATE_DESC) {
pageInfo{
hasNextPage
}
edges {
id
node {
id
idMal
isAdult
status
chapters
episodes
nextAiringEpisode { episode }
type
meanScore
startDate{ year }
isFavourite
format
bannerImage
countryOfOrigin
coverImage { large }
title {
english
romaji
userPreferred
}
mediaListEntry {
progress
private
score(format: POINT_100)
status
}
}
}
}
}
}""".replace("\n", " ").replace(""" """, "")
var hasNextPage = true
val yearMedia = mutableMapOf<String, ArrayList<Media>>()
var page = 0
while (hasNextPage) {
page++
hasNextPage = executeQuery<Query.Studio>(query(page), force = true)?.data?.studio?.media?.let {
it.edges?.forEach { i ->
i.node?.apply {
val status = status.toString()
val year = startDate?.year?.toString() ?: "TBA"
val title = if (status != "CANCELLED") year else status
if (!yearMedia.containsKey(title))
yearMedia[title] = arrayListOf()
yearMedia[title]?.add(Media(this))
}
}
it.pageInfo?.hasNextPage == true
} ?: false
}
if (yearMedia.contains("CANCELLED")) {
val a = yearMedia["CANCELLED"]!!
yearMedia.remove("CANCELLED")
yearMedia["CANCELLED"] = a
}
studio.yearMedia = yearMedia
return studio
}
suspend fun getAuthorDetails(author: Author): Author {
fun query(page: Int = 0) = """ {
Staff(id: ${author.id}) {
id
staffMedia(page: $page,sort:START_DATE_DESC) {
pageInfo{
hasNextPage
}
edges {
staffRole
id
node {
id
idMal
isAdult
status
chapters
episodes
nextAiringEpisode { episode }
type
meanScore
startDate{ year }
isFavourite
format
bannerImage
countryOfOrigin
coverImage { large }
title {
english
romaji
userPreferred
}
mediaListEntry {
progress
private
score(format: POINT_100)
status
}
}
}
}
}
}""".replace("\n", " ").replace(""" """, "")
var hasNextPage = true
val yearMedia = mutableMapOf<String, ArrayList<Media>>()
var page = 0
while (hasNextPage) {
page++
hasNextPage = executeQuery<Query.Author>(query(page), force = true)?.data?.author?.staffMedia?.let {
it.edges?.forEach { i ->
i.node?.apply {
val status = status.toString()
val year = startDate?.year?.toString() ?: "TBA"
val title = if (status != "CANCELLED") year else status
if (!yearMedia.containsKey(title))
yearMedia[title] = arrayListOf()
val media = Media(this)
media.relation = i.staffRole
yearMedia[title]?.add(media)
}
}
it.pageInfo?.hasNextPage == true
} ?: false
}
if (yearMedia.contains("CANCELLED")) {
val a = yearMedia["CANCELLED"]!!
yearMedia.remove("CANCELLED")
yearMedia["CANCELLED"] = a
}
author.yearMedia = yearMedia
return author
}
}

View file

@ -0,0 +1,276 @@
package ani.dantotsu.connections.anilist
import android.content.Context
import androidx.fragment.app.FragmentActivity
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import ani.dantotsu.R
import ani.dantotsu.connections.discord.Discord
import ani.dantotsu.loadData
import ani.dantotsu.connections.mal.MAL
import ani.dantotsu.media.Media
import ani.dantotsu.others.AppUpdater
import ani.dantotsu.snackString
import ani.dantotsu.tryWithSuspend
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
suspend fun getUserId(context: Context, block: () -> Unit) {
CoroutineScope(Dispatchers.IO).launch {
if (Discord.userid == null && Discord.token != null) {
if (!Discord.getUserData())
snackString(context.getString(R.string.error_loading_discord_user_data))
}
}
val anilist = if (Anilist.userid == null && Anilist.token != null) {
if (Anilist.query.getUserData()) {
tryWithSuspend {
if (MAL.token != null && !MAL.query.getUserData())
snackString(context.getString(R.string.error_loading_mal_user_data))
}
true
} else {
snackString(context.getString(R.string.error_loading_anilist_user_data))
false
}
} else true
if(anilist) block.invoke()
}
class AnilistHomeViewModel : ViewModel() {
private val listImages: MutableLiveData<ArrayList<String?>> = MutableLiveData<ArrayList<String?>>(arrayListOf())
fun getListImages(): LiveData<ArrayList<String?>> = listImages
suspend fun setListImages() = listImages.postValue(Anilist.query.getBannerImages())
private val animeContinue: MutableLiveData<ArrayList<Media>> = MutableLiveData<ArrayList<Media>>(null)
fun getAnimeContinue(): LiveData<ArrayList<Media>> = animeContinue
suspend fun setAnimeContinue() = animeContinue.postValue(Anilist.query.continueMedia("ANIME"))
private val animeFav: MutableLiveData<ArrayList<Media>> = MutableLiveData<ArrayList<Media>>(null)
fun getAnimeFav(): LiveData<ArrayList<Media>> = animeFav
suspend fun setAnimeFav() = animeFav.postValue(Anilist.query.favMedia(true))
private val animePlanned: MutableLiveData<ArrayList<Media>> = MutableLiveData<ArrayList<Media>>(null)
fun getAnimePlanned(): LiveData<ArrayList<Media>> = animePlanned
suspend fun setAnimePlanned() = animePlanned.postValue(Anilist.query.continueMedia("ANIME", true))
private val mangaContinue: MutableLiveData<ArrayList<Media>> = MutableLiveData<ArrayList<Media>>(null)
fun getMangaContinue(): LiveData<ArrayList<Media>> = mangaContinue
suspend fun setMangaContinue() = mangaContinue.postValue(Anilist.query.continueMedia("MANGA"))
private val mangaFav: MutableLiveData<ArrayList<Media>> = MutableLiveData<ArrayList<Media>>(null)
fun getMangaFav(): LiveData<ArrayList<Media>> = mangaFav
suspend fun setMangaFav() = mangaFav.postValue(Anilist.query.favMedia(false))
private val mangaPlanned: MutableLiveData<ArrayList<Media>> = MutableLiveData<ArrayList<Media>>(null)
fun getMangaPlanned(): LiveData<ArrayList<Media>> = mangaPlanned
suspend fun setMangaPlanned() = mangaPlanned.postValue(Anilist.query.continueMedia("MANGA", true))
private val recommendation: MutableLiveData<ArrayList<Media>> = MutableLiveData<ArrayList<Media>>(null)
fun getRecommendation(): LiveData<ArrayList<Media>> = recommendation
suspend fun setRecommendation() = recommendation.postValue(Anilist.query.recommendations())
suspend fun loadMain(context: FragmentActivity) {
Anilist.getSavedToken(context)
MAL.getSavedToken(context)
Discord.getSavedToken(context)
if (loadData<Boolean>("check_update") != false) AppUpdater.check(context)
genres.postValue(Anilist.query.getGenresAndTags(context))
}
val empty = MutableLiveData<Boolean>(null)
var loaded: Boolean = false
val genres: MutableLiveData<Boolean?> = MutableLiveData(null)
}
class AnilistAnimeViewModel : ViewModel() {
var searched = false
var notSet = true
lateinit var searchResults: SearchResults
private val type = "ANIME"
private val trending: MutableLiveData<MutableList<Media>> = MutableLiveData<MutableList<Media>>(null)
fun getTrending(): LiveData<MutableList<Media>> = trending
suspend fun loadTrending(i: Int) {
val (season, year) = Anilist.currentSeasons[i]
trending.postValue(
Anilist.query.search(
type,
perPage = 12,
sort = Anilist.sortBy[2],
season = season,
seasonYear = year,
hd = true
)?.results
)
}
private val updated: MutableLiveData<MutableList<Media>> = MutableLiveData<MutableList<Media>>(null)
fun getUpdated(): LiveData<MutableList<Media>> = updated
suspend fun loadUpdated() = updated.postValue(Anilist.query.recentlyUpdated())
private val animePopular = MutableLiveData<SearchResults?>(null)
fun getPopular(): LiveData<SearchResults?> = animePopular
suspend fun loadPopular(
type: String,
search_val: String? = null,
genres: ArrayList<String>? = null,
sort: String = Anilist.sortBy[1],
onList: Boolean = true,
) {
animePopular.postValue(
Anilist.query.search(
type,
search = search_val,
onList = if (onList) null else false,
sort = sort,
genres = genres
)
)
}
suspend fun loadNextPage(r: SearchResults) = animePopular.postValue(
Anilist.query.search(
r.type,
r.page + 1,
r.perPage,
r.search,
r.sort,
r.genres,
r.tags,
r.format,
r.isAdult,
r.onList
)
)
var loaded: Boolean = false
}
class AnilistMangaViewModel : ViewModel() {
var searched = false
var notSet = true
lateinit var searchResults: SearchResults
private val type = "MANGA"
private val trending: MutableLiveData<MutableList<Media>> = MutableLiveData<MutableList<Media>>(null)
fun getTrending(): LiveData<MutableList<Media>> = trending
suspend fun loadTrending() =
trending.postValue(Anilist.query.search(type, perPage = 10, sort = Anilist.sortBy[2], hd = true)?.results)
private val updated: MutableLiveData<MutableList<Media>> = MutableLiveData<MutableList<Media>>(null)
fun getTrendingNovel(): LiveData<MutableList<Media>> = updated
suspend fun loadTrendingNovel() =
updated.postValue(Anilist.query.search(type, perPage = 10, sort = Anilist.sortBy[2], format = "NOVEL")?.results)
private val mangaPopular = MutableLiveData<SearchResults?>(null)
fun getPopular(): LiveData<SearchResults?> = mangaPopular
suspend fun loadPopular(
type: String,
search_val: String? = null,
genres: ArrayList<String>? = null,
sort: String = Anilist.sortBy[1],
onList: Boolean = true,
) {
mangaPopular.postValue(
Anilist.query.search(
type,
search = search_val,
onList = if (onList) null else false,
sort = sort,
genres = genres
)
)
}
suspend fun loadNextPage(r: SearchResults) = mangaPopular.postValue(
Anilist.query.search(
r.type,
r.page + 1,
r.perPage,
r.search,
r.sort,
r.genres,
r.tags,
r.format,
r.isAdult,
r.onList,
r.excludedGenres,
r.excludedTags,
r.seasonYear,
r.season
)
)
var loaded: Boolean = false
}
class AnilistSearch : ViewModel() {
var searched = false
var notSet = true
lateinit var searchResults: SearchResults
private val result: MutableLiveData<SearchResults?> = MutableLiveData<SearchResults?>(null)
fun getSearch(): LiveData<SearchResults?> = result
suspend fun loadSearch(r: SearchResults) = result.postValue(
Anilist.query.search(
r.type,
r.page,
r.perPage,
r.search,
r.sort,
r.genres,
r.tags,
r.format,
r.isAdult,
r.onList,
r.excludedGenres,
r.excludedTags,
r.seasonYear,
r.season
)
)
suspend fun loadNextPage(r: SearchResults) = result.postValue(
Anilist.query.search(
r.type,
r.page + 1,
r.perPage,
r.search,
r.sort,
r.genres,
r.tags,
r.format,
r.isAdult,
r.onList,
r.excludedGenres,
r.excludedTags,
r.seasonYear,
r.season
)
)
}
class GenresViewModel : ViewModel() {
var genres: MutableMap<String, String>? = null
var done = false
var doneListener: (() -> Unit)? = null
suspend fun loadGenres(genre: ArrayList<String>, listener: (Pair<String, String>) -> Unit) {
if (genres == null) {
genres = mutableMapOf()
Anilist.query.getGenres(genre) {
genres!![it.first] = it.second
listener.invoke(it)
if (genres!!.size == genre.size) {
done = true
doneListener?.invoke()
}
}
}
}
}

View file

@ -0,0 +1,12 @@
package ani.dantotsu.connections.anilist
import java.io.Serializable
data class BannerImage(
val url: String?,
var time: Long,
) : Serializable {
fun checkTime(): Boolean {
return (System.currentTimeMillis() - time) >= (1000 * 60 * 60 * 6)
}
}

View file

@ -0,0 +1,10 @@
package ani.dantotsu.connections.anilist
import java.io.Serializable
data class Genre(
val name: String,
var id: Int,
var thumbnail: String,
var time: Long,
) : Serializable

View file

@ -0,0 +1,27 @@
package ani.dantotsu.connections.anilist
import android.content.Context
import android.net.Uri
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import ani.dantotsu.logError
import ani.dantotsu.logger
import ani.dantotsu.startMainActivity
class Login : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val data: Uri? = intent?.data
logger(data.toString())
try {
Anilist.token = Regex("""(?<=access_token=).+(?=&token_type)""").find(data.toString())!!.value
val filename = "anilistToken"
this.openFileOutput(filename, Context.MODE_PRIVATE).use {
it.write(Anilist.token!!.toByteArray())
}
} catch (e: Exception) {
logError(e)
}
startMainActivity(this)
}
}

View file

@ -0,0 +1,73 @@
package ani.dantotsu.connections.anilist
import ani.dantotsu.R
import ani.dantotsu.currContext
import ani.dantotsu.media.Media
import java.io.Serializable
data class SearchResults(
val type: String,
var isAdult: Boolean,
var onList: Boolean? = null,
var perPage: Int? = null,
var search: String? = null,
var sort: String? = null,
var genres: MutableList<String>? = null,
var excludedGenres: MutableList<String>? = null,
var tags: MutableList<String>? = null,
var excludedTags: MutableList<String>? = null,
var format: String? = null,
var seasonYear: Int? = null,
var season: String? = null,
var page: Int = 1,
var results: MutableList<Media>,
var hasNextPage: Boolean,
) : Serializable {
fun toChipList(): List<SearchChip> {
val list = mutableListOf<SearchChip>()
sort?.let {
val c = currContext()!!
list.add(SearchChip("SORT", c.getString(R.string.filter_sort, c.resources.getStringArray(R.array.sort_by)[Anilist.sortBy.indexOf(it)])))
}
format?.let {
list.add(SearchChip("FORMAT", currContext()!!.getString(R.string.filter_format, it)))
}
season?.let {
list.add(SearchChip("SEASON", it))
}
seasonYear?.let {
list.add(SearchChip("SEASON_YEAR", it.toString()))
}
genres?.forEach {
list.add(SearchChip("GENRE", it))
}
excludedGenres?.forEach {
list.add(SearchChip("EXCLUDED_GENRE", currContext()!!.getString(R.string.filter_exclude, it)))
}
tags?.forEach {
list.add(SearchChip("TAG", it))
}
excludedTags?.forEach {
list.add(SearchChip("EXCLUDED_TAG", currContext()!!.getString(R.string.filter_exclude, it)))
}
return list
}
fun removeChip(chip: SearchChip) {
when (chip.type) {
"SORT" -> sort = null
"FORMAT" -> format = null
"SEASON" -> season = null
"SEASON_YEAR" -> seasonYear = null
"GENRE" -> genres?.remove(chip.text)
"EXCLUDED_GENRE" -> excludedGenres?.remove(chip.text)
"TAG" -> tags?.remove(chip.text)
"EXCLUDED_TAG" -> excludedTags?.remove(chip.text)
}
}
data class SearchChip(
val type: String,
val text: String
)
}

View file

@ -0,0 +1,24 @@
package ani.dantotsu.connections.anilist
import android.app.Activity
import android.net.Uri
import android.os.Bundle
import androidx.core.os.bundleOf
import ani.dantotsu.loadMedia
import ani.dantotsu.startMainActivity
class UrlMedia : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
var id: Int? = intent?.extras?.getInt("media", 0) ?: 0
var isMAL = false
var continueMedia = true
if (id == 0) {
continueMedia = false
val data: Uri? = intent?.data
isMAL = data?.host != "anilist.co"
id = data?.pathSegments?.getOrNull(1)?.toIntOrNull()
} else loadMedia = id
startMainActivity(this, bundleOf("mediaId" to id, "mal" to isMAL, "continue" to continueMedia))
}
}

View file

@ -0,0 +1,121 @@
package ani.dantotsu.connections.anilist.api
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class Character(
// The id of the character
@SerialName("id") var id: Int,
// The names of the character
@SerialName("name") var name: CharacterName?,
// Character images
@SerialName("image") var image: CharacterImage?,
// A general description of the character
@SerialName("description") var description: String?,
// The character's gender. Usually Male, Female, or Non-binary but can be any string.
@SerialName("gender") var gender: String?,
// The character's birth date
@SerialName("dateOfBirth") var dateOfBirth: FuzzyDate?,
// The character's age. Note this is a string, not an int, it may contain further text and additional ages.
@SerialName("age") var age: String?,
// The characters blood type
@SerialName("bloodType") var bloodType: String?,
// If the character is marked as favourite by the currently authenticated user
@SerialName("isFavourite") var isFavourite: Boolean?,
// If the character is blocked from being added to favourites
@SerialName("isFavouriteBlocked") var isFavouriteBlocked: Boolean?,
// The url for the character page on the AniList website
@SerialName("siteUrl") var siteUrl: String?,
// Media that includes the character
@SerialName("media") var media: MediaConnection?,
// The amount of user's who have favourited the character
@SerialName("favourites") var favourites: Int?,
// Notes for site moderators
@SerialName("modNotes") var modNotes: String?,
)
@Serializable
data class CharacterConnection(
@SerialName("edges") var edges: List<CharacterEdge>?,
@SerialName("nodes") var nodes: List<Character>?,
// The pagination information
// @SerialName("pageInfo") var pageInfo: PageInfo?,
)
@Serializable
data class CharacterEdge(
@SerialName("node") var node: Character?,
// The id of the connection
@SerialName("id") var id: Int?,
// The characters role in the media
@SerialName("role") var role: String?,
// Media specific character name
@SerialName("name") var name: String?,
// The voice actors of the character
// @SerialName("voiceActors") var voiceActors: List<Staff>?,
// The voice actors of the character with role date
// @SerialName("voiceActorRoles") var voiceActorRoles: List<StaffRoleType>?,
// The media the character is in
@SerialName("media") var media: List<Media>?,
// The order the character should be displayed from the users favourites
@SerialName("favouriteOrder") var favouriteOrder: Int?,
)
@Serializable
data class CharacterName(
// The character's given name
@SerialName("first") var first: String?,
// The character's middle name
@SerialName("middle") var middle: String?,
// The character's surname
@SerialName("last") var last: String?,
// The character's first and last name
@SerialName("full") var full: String?,
// The character's full name in their native language
@SerialName("native") var native: String?,
// Other names the character might be referred to as
@SerialName("alternative") var alternative: List<String>?,
// Other names the character might be referred to as but are spoilers
@SerialName("alternativeSpoiler") var alternativeSpoiler: List<String>?,
// The currently authenticated users preferred name language. Default romaji for non-authenticated
@SerialName("userPreferred") var userPreferred: String?,
)
@Serializable
data class CharacterImage(
// The character's image of media at its largest size
@SerialName("large") var large: String?,
// The character's image of media at medium size
@SerialName("medium") var medium: String?,
)

View file

@ -0,0 +1,185 @@
package ani.dantotsu.connections.anilist.api
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
class Query{
@Serializable
data class Viewer(
@SerialName("data")
val data : Data?
){
@Serializable
data class Data(
@SerialName("Viewer")
val user: ani.dantotsu.connections.anilist.api.User?
)
}
@Serializable
data class Media(
@SerialName("data")
val data : Data?
){
@Serializable
data class Data(
@SerialName("Media")
val media: ani.dantotsu.connections.anilist.api.Media?
)
}
@Serializable
data class Page(
@SerialName("data")
val data : Data?
){
@Serializable
data class Data(
@SerialName("Page")
val page : ani.dantotsu.connections.anilist.api.Page?
)
}
// data class AiringSchedule(
// val data : Data?
// ){
// data class Data(
// val AiringSchedule: ani.dantotsu.connections.anilist.api.AiringSchedule?
// )
// }
@Serializable
data class Character(
@SerialName("data")
val data : Data?
){
@Serializable
data class Data(
@SerialName("Character")
val character: ani.dantotsu.connections.anilist.api.Character?
)
}
@Serializable
data class Studio(
@SerialName("data")
val data: Data?
){
@Serializable
data class Data(
@SerialName("Studio")
val studio: ani.dantotsu.connections.anilist.api.Studio?
)
}
@Serializable
data class Author(
@SerialName("data")
val data: Data?
){
@Serializable
data class Data(
@SerialName("Staff")
val author: Staff?
)
}
// data class MediaList(
// val data: Data?
// ){
// data class Data(
// val MediaList: ani.dantotsu.connections.anilist.api.MediaList?
// )
// }
@Serializable
data class MediaListCollection(
@SerialName("data")
val data : Data?
){
@Serializable
data class Data(
@SerialName("MediaListCollection")
val mediaListCollection: ani.dantotsu.connections.anilist.api.MediaListCollection?
)
}
@Serializable
data class GenreCollection(
@SerialName("data")
val data: Data
){
@Serializable
data class Data(
@SerialName("GenreCollection")
val genreCollection: List<String>?
)
}
@Serializable
data class MediaTagCollection(
@SerialName("data")
val data: Data
){
@Serializable
data class Data(
@SerialName("MediaTagCollection")
val mediaTagCollection: List<MediaTag>?
)
}
@Serializable
data class User(
@SerialName("data")
val data: Data
){
@Serializable
data class Data(
@SerialName("User")
val user: ani.dantotsu.connections.anilist.api.User?
)
}
}
//data class WhaData(
// val Studio: Studio?,
//
// // Follow query
// val Following: User?,
//
// // Follow query
// val Follower: User?,
//
// // Thread query
// val Thread: Thread?,
//
// // Recommendation query
// val Recommendation: Recommendation?,
//
// // Like query
// val Like: User?,
// // Review query
// val Review: Review?,
//
// // Activity query
// val Activity: ActivityUnion?,
//
// // Activity reply query
// val ActivityReply: ActivityReply?,
// // Comment query
// val ThreadComment: List<ThreadComment>?,
// // Notification query
// val Notification: NotificationUnion?,
// // Media Trend query
// val MediaTrend: MediaTrend?,
// // Provide AniList markdown to be converted to html (Requires auth)
// val Markdown: ParsedMarkdown?,
// // SiteStatistics: SiteStatistics
// val AniChartUser: AniChartUser?,
//)

View file

@ -0,0 +1,61 @@
package ani.dantotsu.connections.anilist.api
import kotlinx.serialization.SerialName
import java.io.Serializable
import java.text.DateFormatSymbols
import java.util.*
@kotlinx.serialization.Serializable
data class FuzzyDate(
@SerialName("year") val year: Int? = null,
@SerialName("month") val month: Int? = null,
@SerialName("day") val day: Int? = null,
) : Serializable, Comparable<FuzzyDate> {
fun isEmpty(): Boolean {
return year == null && month == null && day == null
}
override fun toString(): String {
return if ( isEmpty() ) "??" else toStringOrEmpty()
}
fun toStringOrEmpty(): String {
return listOfNotNull(
day?.toString(),
month?.let { DateFormatSymbols().months.elementAt(it - 1) },
year?.toString()
).joinToString(" ")
}
fun getToday(): FuzzyDate {
val cal = Calendar.getInstance()
return FuzzyDate(cal.get(Calendar.YEAR), cal.get(Calendar.MONTH) + 1, cal.get(Calendar.DAY_OF_MONTH))
}
fun toVariableString(): String {
return listOfNotNull(
year?.let {"year:$it"},
month?.let {"month:$it"},
day?.let {"day:$it"}
).joinToString(",", "{", "}")
}
fun toMALString(): String {
val padding = '0'
val values = listOf(
year?.toString()?.padStart(4, padding),
month?.toString()?.padStart(2, padding),
day?.toString()?.padStart(2, padding)
)
return values.takeWhile {it is String}.joinToString("-")
}
// fun toInt(): Int {
// return 10000 * (this.year ?: 0) + 100 * (this.month ?: 0) + (this.day ?: 0)
// }
override fun compareTo(other: FuzzyDate): Int = when {
year != other.year -> (year ?: 0) - (other.year ?: 0)
month != other.month -> (month ?: 0) - (other.month ?: 0)
else -> (day ?: 0) - (other.day ?: 0)
}
}

View file

@ -0,0 +1,529 @@
@file:Suppress("unused")
package ani.dantotsu.connections.anilist.api
import ani.dantotsu.R
import ani.dantotsu.currContext
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class Media(
// The id of the media
@SerialName("id") var id: Int,
// The mal id of the media
@SerialName("idMal") var idMal: Int?,
// The official titles of the media in various languages
@SerialName("title") var title: MediaTitle?,
// The type of the media; anime or manga
@SerialName("type") var type: MediaType?,
// The format the media was released in
@SerialName("format") var format: MediaFormat?,
// The current releasing status of the media
@SerialName("status") var status: MediaStatus?,
// Short description of the media's story and characters
@SerialName("description") var description: String?,
// The first official release date of the media
@SerialName("startDate") var startDate: FuzzyDate?,
// The last official release date of the media
@SerialName("endDate") var endDate: FuzzyDate?,
// The season the media was initially released in
@SerialName("season") var season: MediaSeason?,
// The season year the media was initially released in
@SerialName("seasonYear") var seasonYear: Int?,
// The year & season the media was initially released in
@SerialName("seasonInt") var seasonInt: Int?,
// The amount of episodes the anime has when complete
@SerialName("episodes") var episodes: Int?,
// The general length of each anime episode in minutes
@SerialName("duration") var duration: Int?,
// The amount of chapters the manga has when complete
@SerialName("chapters") var chapters: Int?,
// The amount of volumes the manga has when complete
@SerialName("volumes") var volumes: Int?,
// Where the media was created. (ISO 3166-1 alpha-2)
// Originally a "CountryCode"
@SerialName("countryOfOrigin") var countryOfOrigin: String?,
// If the media is officially licensed or a self-published doujin release
@SerialName("isLicensed") var isLicensed: Boolean?,
// Source type the media was adapted from.
@SerialName("source") var source: MediaSource?,
// Official Twitter hashtags for the media
@SerialName("hashtag") var hashtag: String?,
// Media trailer or advertisement
@SerialName("trailer") var trailer: MediaTrailer?,
// When the media's data was last updated
@SerialName("updatedAt") var updatedAt: Int?,
// The cover images of the media
@SerialName("coverImage") var coverImage: MediaCoverImage?,
// The banner image of the media
@SerialName("bannerImage") var bannerImage: String?,
// The genres of the media
@SerialName("genres") var genres: List<String>?,
// Alternative titles of the media
@SerialName("synonyms") var synonyms: List<String>?,
// A weighted average score of all the user's scores of the media
@SerialName("averageScore") var averageScore: Int?,
// Mean score of all the user's scores of the media
@SerialName("meanScore") var meanScore: Int?,
// The number of users with the media on their list
@SerialName("popularity") var popularity: Int?,
// Locked media may not be added to lists our favorited. This may be due to the entry pending for deletion or other reasons.
@SerialName("isLocked") var isLocked: Boolean?,
// The amount of related activity in the past hour
@SerialName("trending") var trending: Int?,
// The amount of user's who have favourited the media
@SerialName("favourites") var favourites: Int?,
// List of tags that describes elements and themes of the media
@SerialName("tags") var tags: List<MediaTag>?,
// Other media in the same or connecting franchise
@SerialName("relations") var relations: MediaConnection?,
// The characters in the media
@SerialName("characters") var characters: CharacterConnection?,
// The staff who produced the media
@SerialName("staffPreview") var staff: StaffConnection?,
// The companies who produced the media
@SerialName("studios") var studios: StudioConnection?,
// If the media is marked as favourite by the current authenticated user
@SerialName("isFavourite") var isFavourite: Boolean?,
// If the media is blocked from being added to favourites
@SerialName("isFavouriteBlocked") var isFavouriteBlocked: Boolean?,
// If the media is intended only for 18+ adult audiences
@SerialName("isAdult") var isAdult: Boolean?,
// The media's next episode airing schedule
@SerialName("nextAiringEpisode") var nextAiringEpisode: AiringSchedule?,
// The media's entire airing schedule
// @SerialName("airingSchedule") var airingSchedule: AiringScheduleConnection?,
// The media's daily trend stats
// @SerialName("trends") var trends: MediaTrendConnection?,
// External links to another site related to the media
@SerialName("externalLinks") var externalLinks: List<MediaExternalLink>?,
// Data and links to legal streaming episodes on external sites
// @SerialName("streamingEpisodes") var streamingEpisodes: List<MediaStreamingEpisode>?,
// The ranking of the media in a particular time span and format compared to other media
// @SerialName("rankings") var rankings: List<MediaRank>?,
// The authenticated user's media list entry for the media
@SerialName("mediaListEntry") var mediaListEntry: MediaList?,
// User reviews of the media
// @SerialName("reviews") var reviews: ReviewConnection?,
// User recommendations for similar media
@SerialName("recommendations") var recommendations: RecommendationConnection?,
//
// @SerialName("stats") var stats: MediaStats?,
// The url for the media page on the AniList website
@SerialName("siteUrl") var siteUrl: String?,
// If the media should have forum thread automatically created for it on airing episode release
@SerialName("autoCreateForumThread") var autoCreateForumThread: Boolean?,
// If the media is blocked from being recommended to/from
@SerialName("isRecommendationBlocked") var isRecommendationBlocked: Boolean?,
// If the media is blocked from being reviewed
@SerialName("isReviewBlocked") var isReviewBlocked: Boolean?,
// Notes for site moderators
@SerialName("modNotes") var modNotes: String?,
)
@Serializable
data class MediaTitle(
// The romanization of the native language title
@SerialName("romaji") var romaji: String,
// The official english title
@SerialName("english") var english: String?,
// Official title in it's native language
@SerialName("native") var native: String?,
// The currently authenticated users preferred title language. Default romaji for non-authenticated
@SerialName("userPreferred") var userPreferred: String,
)
@Serializable
enum class MediaType {
ANIME, MANGA;
override fun toString(): String {
return super.toString().replace("_", " ")
}
}
@Serializable
enum class MediaStatus {
FINISHED, RELEASING, NOT_YET_RELEASED, CANCELLED, HIATUS;
override fun toString(): String {
return when (super.toString()) {
"FINISHED" -> currContext()!!.getString(R.string.status_finished)
"RELEASING" -> currContext()!!.getString(R.string.status_releasing)
"NOT_YET_RELEASED" -> currContext()!!.getString(R.string.status_not_yet_released)
"CANCELLED" -> currContext()!!.getString(R.string.status_cancelled)
"HIATUS" -> currContext()!!.getString(R.string.status_hiatus)
else -> ""
}
}
}
@Serializable
data class AiringSchedule(
// The id of the airing schedule item
@SerialName("id") var id: Int?,
// The time the episode airs at
@SerialName("airingAt") var airingAt: Int?,
// Seconds until episode starts airing
@SerialName("timeUntilAiring") var timeUntilAiring: Int?,
// The airing episode number
@SerialName("episode") var episode: Int?,
// The associate media id of the airing episode
@SerialName("mediaId") var mediaId: Int?,
// The associate media of the airing episode
@SerialName("media") var media: Media?,
)
@Serializable
data class MediaCoverImage(
// The cover image url of the media at its largest size. If this size isn't available, large will be provided instead.
@SerialName("extraLarge") var extraLarge: String?,
// The cover image url of the media at a large size
@SerialName("large") var large: String?,
// The cover image url of the media at medium size
@SerialName("medium") var medium: String?,
// Average #hex color of cover image
@SerialName("color") var color: String?,
)
@Serializable
data class MediaList(
// The id of the list entry
@SerialName("id") var id: Int?,
// The id of the user owner of the list entry
@SerialName("userId") var userId: Int?,
// The id of the media
@SerialName("mediaId") var mediaId: Int?,
// The watching/reading status
@SerialName("status") var status: MediaListStatus?,
// The score of the entry
@SerialName("score") var score: Float?,
// The amount of episodes/chapters consumed by the user
@SerialName("progress") var progress: Int?,
// The amount of volumes read by the user
@SerialName("progressVolumes") var progressVolumes: Int?,
// The amount of times the user has rewatched/read the media
@SerialName("repeat") var repeat: Int?,
// Priority of planning
@SerialName("priority") var priority: Int?,
// If the entry should only be visible to authenticated user
@SerialName("private") var private: Boolean?,
// Text notes
@SerialName("notes") var notes: String?,
// If the entry shown be hidden from non-custom lists
@SerialName("hiddenFromStatusLists") var hiddenFromStatusLists: Boolean?,
// Map of booleans for which custom lists the entry are in
@SerialName("customLists") var customLists: Map<String,Boolean>?,
// Map of advanced scores with name keys
// @SerialName("advancedScores") var advancedScores: Json?,
// When the entry was started by the user
@SerialName("startedAt") var startedAt: FuzzyDate?,
// When the entry was completed by the user
@SerialName("completedAt") var completedAt: FuzzyDate?,
// When the entry data was last updated
@SerialName("updatedAt") var updatedAt: Int?,
// When the entry data was created
@SerialName("createdAt") var createdAt: Int?,
@SerialName("media") var media: Media?,
@SerialName("user") var user: User?
)
@Serializable
enum class MediaListStatus {
CURRENT, PLANNING, COMPLETED, DROPPED, PAUSED, REPEATING;
override fun toString(): String {
return super.toString().replace("_", " ")
}
}
@Serializable
enum class MediaSource {
ORIGINAL, MANGA, LIGHT_NOVEL, VISUAL_NOVEL, VIDEO_GAME, OTHER, NOVEL, DOUJINSHI, ANIME, WEB_NOVEL, LIVE_ACTION, GAME, COMIC, MULTIMEDIA_PROJECT, PICTURE_BOOK;
override fun toString(): String {
return super.toString().replace("_", " ")
}
}
@Serializable
enum class MediaFormat {
TV, TV_SHORT, MOVIE, SPECIAL, OVA, ONA, MUSIC, MANGA, NOVEL, ONE_SHOT;
override fun toString(): String {
return super.toString().replace("_", " ")
}
}
@Serializable
data class MediaTrailer(
// The trailer video id
@SerialName("id") var id: String?,
// The site the video is hosted by (Currently either youtube or dailymotion)
@SerialName("site") var site: String?,
// The url for the thumbnail image of the video
@SerialName("thumbnail") var thumbnail: String?,
)
@Serializable
data class MediaTagCollection(
@SerialName("tags") var tags : List<MediaTag>?
)
@Serializable
data class MediaTag(
// The id of the tag
@SerialName("id") var id: Int?,
// The name of the tag
@SerialName("name") var name: String,
// A general description of the tag
@SerialName("description") var description: String?,
// The categories of tags this tag belongs to
@SerialName("category") var category: String?,
// The relevance ranking of the tag out of the 100 for this media
@SerialName("rank") var rank: Int?,
// If the tag could be a spoiler for any media
@SerialName("isGeneralSpoiler") var isGeneralSpoiler: Boolean?,
// If the tag is a spoiler for this media
@SerialName("isMediaSpoiler") var isMediaSpoiler: Boolean?,
// If the tag is only for adult 18+ media
@SerialName("isAdult") var isAdult: Boolean?,
// The user who submitted the tag
@SerialName("userId") var userId: Int?,
)
@Serializable
data class MediaConnection(
@SerialName("edges") var edges: List<MediaEdge>?,
@SerialName("nodes") var nodes: List<Media>?,
// The pagination information
@SerialName("pageInfo") var pageInfo: PageInfo?,
)
@Serializable
data class MediaEdge(
//
@SerialName("node") var node: Media?,
// The id of the connection
@SerialName("id") var id: Int?,
// The type of relation to the parent model
@SerialName("relationType") var relationType: MediaRelation?,
// If the studio is the main animation studio of the media (For Studio->MediaConnection field only)
@SerialName("isMainStudio") var isMainStudio: Boolean?,
// The characters in the media voiced by the parent actor
@SerialName("characters") var characters: List<Character>?,
// The characters role in the media
@SerialName("characterRole") var characterRole: String?,
// Media specific character name
@SerialName("characterName") var characterName: String?,
// Notes regarding the VA's role for the character
@SerialName("roleNotes") var roleNotes: String?,
// Used for grouping roles where multiple dubs exist for the same language. Either dubbing company name or language variant.
@SerialName("dubGroup") var dubGroup: String?,
// The role of the staff member in the production of the media
@SerialName("staffRole") var staffRole: String?,
// The voice actors of the character
// @SerialName("voiceActors") var voiceActors: List<Staff>?,
// The voice actors of the character with role date
// @SerialName("voiceActorRoles") var voiceActorRoles: List<StaffRoleType>?,
// The order the media should be displayed from the users favourites
@SerialName("favouriteOrder") var favouriteOrder: Int?,
)
@Serializable
enum class MediaRelation {
ADAPTATION, PREQUEL, SEQUEL, PARENT, SIDE_STORY, CHARACTER, SUMMARY, ALTERNATIVE, SPIN_OFF, OTHER, SOURCE, COMPILATION, CONTAINS;
override fun toString(): String {
return when (super.toString()) {
"ADAPTATION" -> currContext()!!.getString(R.string.type_adaptation)
"PARENT" -> currContext()!!.getString(R.string.type_parent)
"CHARACTER" -> currContext()!!.getString(R.string.type_character)
"SUMMARY" -> currContext()!!.getString(R.string.type_summary)
"ALTERNATIVE" -> currContext()!!.getString(R.string.type_alternative)
"OTHER" -> currContext()!!.getString(R.string.type_other)
"SOURCE" -> currContext()!!.getString(R.string.type_source)
"CONTAINS" -> currContext()!!.getString(R.string.type_contains)
else -> super.toString().replace("_", " ")
}
}
}
@Serializable
enum class MediaSeason {
WINTER, SPRING, SUMMER, FALL;
}
@Serializable
data class MediaExternalLink(
// The id of the external link
@SerialName("id") var id: Int?,
// The url of the external link or base url of link source
@SerialName("url") var url: String?,
// The links website site name
@SerialName("site") var site: String,
// The links website site id
@SerialName("siteId") var siteId: Int?,
@SerialName("type") var type: ExternalLinkType?,
// Language the site content is in. See Staff language field for values.
@SerialName("language") var language: String?,
@SerialName("color") var color: String?,
// The icon image url of the site. Not available for all links. Transparent PNG 64x64
@SerialName("icon") var icon: String?,
// isDisabled: Boolean
@SerialName("notes") var notes: String?,
)
@Serializable
enum class ExternalLinkType {
INFO, STREAMING, SOCIAL;
override fun toString(): String {
return super.toString().replace("_", " ")
}
}
@Serializable
data class MediaListCollection(
// Grouped media list entries
@SerialName("lists") var lists: List<MediaListGroup>?,
// The owner of the list
@SerialName("user") var user: User?,
// If there is another chunk
@SerialName("hasNextChunk") var hasNextChunk: Boolean?,
)
@Serializable
data class MediaListGroup(
// Media list entries
@SerialName("entries") var entries: List<MediaList>?,
@SerialName("name") var name: String?,
@SerialName("isCustomList") var isCustomList: Boolean?,
@SerialName("isSplitCompletedList") var isSplitCompletedList: Boolean?,
@SerialName("status") var status: MediaListStatus?,
)

View file

@ -0,0 +1,64 @@
package ani.dantotsu.connections.anilist.api
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class Page(
// The pagination information
@SerialName("pageInfo") var pageInfo: PageInfo?,
@SerialName("users") var users: List<User>?,
@SerialName("media") var media: List<Media>?,
@SerialName("characters") var characters: List<Character>?,
@SerialName("staff") var staff: List<Staff>?,
@SerialName("studios") var studios: List<Studio>?,
@SerialName("mediaList") var mediaList: List<MediaList>?,
@SerialName("airingSchedules") var airingSchedules: List<AiringSchedule>?,
// @SerialName("mediaTrends") var mediaTrends: List<MediaTrend>?,
// @SerialName("notifications") var notifications: List<NotificationUnion>?,
@SerialName("followers") var followers: List<User>?,
@SerialName("following") var following: List<User>?,
// @SerialName("activities") var activities: List<ActivityUnion>?,
// @SerialName("activityReplies") var activityReplies: List<ActivityReply>?,
// @SerialName("threads") var threads: List<Thread>?,
// @SerialName("threadComments") var threadComments: List<ThreadComment>?,
// @SerialName("reviews") var reviews: List<Review>?,
@SerialName("recommendations") var recommendations: List<Recommendation>?,
@SerialName("likes") var likes: List<User>?,
)
@Serializable
data class PageInfo(
// The total number of items. Note: This value is not guaranteed to be accurate, do not rely on this for logic
@SerialName("total") var total: Int?,
// The count on a page
@SerialName("perPage") var perPage: Int?,
// The current page
@SerialName("currentPage") var currentPage: Int?,
// The last page
@SerialName("lastPage") var lastPage: Int?,
// If there is another page
@SerialName("hasNextPage") var hasNextPage: Boolean?,
)

View file

@ -0,0 +1,34 @@
package ani.dantotsu.connections.anilist.api
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class Recommendation(
// The id of the recommendation
@SerialName("id") var id: Int?,
// Users rating of the recommendation
@SerialName("rating") var rating: Int?,
// The rating of the recommendation by currently authenticated user
// @SerialName("userRating") var userRating: RecommendationRating?,
// The media the recommendation is from
@SerialName("media") var media: Media?,
// The recommended media
@SerialName("mediaRecommendation") var mediaRecommendation: Media?,
// The user that first created the recommendation
@SerialName("user") var user: User?,
)
@Serializable
data class RecommendationConnection(
//@SerialName("edges") var edges: List<RecommendationEdge>?,
@SerialName("nodes") var nodes: List<Recommendation>?,
// The pagination information
//@SerialName("pageInfo") var pageInfo: PageInfo?,
)

View file

@ -0,0 +1,101 @@
package ani.dantotsu.connections.anilist.api
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class Staff(
// The id of the staff member
@SerialName("id") var id: Int,
// The names of the staff member
@SerialName("name") var name: StaffName?,
// The primary language of the staff member. Current values: Japanese, English, Korean, Italian, Spanish, Portuguese, French, German, Hebrew, Hungarian, Chinese, Arabic, Filipino, Catalan, Finnish, Turkish, Dutch, Swedish, Thai, Tagalog, Malaysian, Indonesian, Vietnamese, Nepali, Hindi, Urdu
@SerialName("languageV2") var languageV2: String?,
// The staff images
// @SerialName("image") var image: StaffImage?,
// A general description of the staff member
@SerialName("description") var description: String?,
// The person's primary occupations
@SerialName("primaryOccupations") var primaryOccupations: List<String>?,
// The staff's gender. Usually Male, Female, or Non-binary but can be any string.
@SerialName("gender") var gender: String?,
@SerialName("dateOfBirth") var dateOfBirth: FuzzyDate?,
@SerialName("dateOfDeath") var dateOfDeath: FuzzyDate?,
// The person's age in years
@SerialName("age") var age: Int?,
// [startYear, endYear] (If the 2nd value is not present staff is still active)
@SerialName("yearsActive") var yearsActive: List<Int>?,
// The persons birthplace or hometown
@SerialName("homeTown") var homeTown: String?,
// The persons blood type
@SerialName("bloodType") var bloodType: String?,
// If the staff member is marked as favourite by the currently authenticated user
@SerialName("isFavourite") var isFavourite: Boolean?,
// If the staff member is blocked from being added to favourites
@SerialName("isFavouriteBlocked") var isFavouriteBlocked: Boolean?,
// The url for the staff page on the AniList website
@SerialName("siteUrl") var siteUrl: String?,
// Media where the staff member has a production role
@SerialName("staffMedia") var staffMedia: MediaConnection?,
// Characters voiced by the actor
@SerialName("characters") var characters: CharacterConnection?,
// Media the actor voiced characters in. (Same data as characters with media as node instead of characters)
@SerialName("characterMedia") var characterMedia: MediaConnection?,
// Staff member that the submission is referencing
@SerialName("staff") var staff: Staff?,
// Submitter for the submission
@SerialName("submitter") var submitter: User?,
// Status of the submission
@SerialName("submissionStatus") var submissionStatus: Int?,
// Inner details of submission status
@SerialName("submissionNotes") var submissionNotes: String?,
// The amount of user's who have favourited the staff member
@SerialName("favourites") var favourites: Int?,
// Notes for site moderators
@SerialName("modNotes") var modNotes: String?,
)
@Serializable
data class StaffName (
var userPreferred:String?
)
@Serializable
data class StaffConnection(
@SerialName("edges") var edges: List<StaffEdge>?,
@SerialName("nodes") var nodes: List<Staff>?,
// The pagination information
// @SerialName("pageInfo") var pageInfo: PageInfo?,
)
@Serializable
data class StaffEdge(
var role:String?,
var node: Staff?
)

View file

@ -0,0 +1,39 @@
package ani.dantotsu.connections.anilist.api
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class Studio(
// The id of the studio
@SerialName("id") var id: Int,
// The name of the studio
// Originally non-nullable, needs to be nullable due to it not being always queried
@SerialName("name") var name: String?,
// If the studio is an animation studio or a different kind of company
@SerialName("isAnimationStudio") var isAnimationStudio: Boolean?,
// The media the studio has worked on
@SerialName("media") var media: MediaConnection?,
// The url for the studio page on the AniList website
@SerialName("siteUrl") var siteUrl: String?,
// If the studio is marked as favourite by the currently authenticated user
@SerialName("isFavourite") var isFavourite: Boolean?,
// The amount of user's who have favourited the studio
@SerialName("favourites") var favourites: Int?,
)
@Serializable
data class StudioConnection(
//@SerialName("edges") var edges: List<StudioEdge>?,
@SerialName("nodes") var nodes: List<Studio>?,
// The pagination information
//@SerialName("pageInfo") var pageInfo: PageInfo?,
)

View file

@ -0,0 +1,196 @@
package ani.dantotsu.connections.anilist.api
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class User(
// The id of the user
@SerialName("id") var id: Int,
// The name of the user
@SerialName("name") var name: String?,
// The bio written by user (Markdown)
// @SerialName("about") var about: String?,
// The user's avatar images
@SerialName("avatar") var avatar: UserAvatar?,
// The user's banner images
@SerialName("bannerImage") var bannerImage: String?,
// If the authenticated user if following this user
// @SerialName("isFollowing") var isFollowing: Boolean?,
// If this user if following the authenticated user
// @SerialName("isFollower") var isFollower: Boolean?,
// If the user is blocked by the authenticated user
// @SerialName("isBlocked") var isBlocked: Boolean?,
// FIXME: No documentation is provided for "Json"
// @SerialName("bans") var bans: Json?,
// The user's general options
@SerialName("options") var options: UserOptions?,
// The user's media list options
@SerialName("mediaListOptions") var mediaListOptions: MediaListOptions?,
// The users favourites
@SerialName("favourites") var favourites: Favourites?,
// The users anime & manga list statistics
@SerialName("statistics") var statistics: UserStatisticTypes?,
// The number of unread notifications the user has
// @SerialName("unreadNotificationCount") var unreadNotificationCount: Int?,
// The url for the user page on the AniList website
// @SerialName("siteUrl") var siteUrl: String?,
// The donation tier of the user
// @SerialName("donatorTier") var donatorTier: Int?,
// Custom donation badge text
// @SerialName("donatorBadge") var donatorBadge: String?,
// The user's moderator roles if they are a site moderator
// @SerialName("moderatorRoles") var moderatorRoles: List<ModRole>?,
// When the user's account was created. (Does not exist for accounts created before 2020)
// @SerialName("createdAt") var createdAt: Int?,
// When the user's data was last updated
// @SerialName("updatedAt") var updatedAt: Int?,
// The user's previously used names.
// @SerialName("previousNames") var previousNames: List<UserPreviousName>?,
)
@Serializable
data class UserOptions(
// The language the user wants to see media titles in
// @SerialName("titleLanguage") var titleLanguage: UserTitleLanguage?,
// Whether the user has enabled viewing of 18+ content
@SerialName("displayAdultContent") var displayAdultContent: Boolean?,
// Whether the user receives notifications when a show they are watching aires
@SerialName("airingNotifications") var airingNotifications: Boolean?,
//
// Profile highlight color (blue, purple, pink, orange, red, green, gray)
@SerialName("profileColor") var profileColor: String?,
//
// // Notification options
// // @SerialName("notificationOptions") var notificationOptions: List<NotificationOption>?,
//
// // The user's timezone offset (Auth user only)
// @SerialName("timezone") var timezone: String?,
//
// // Minutes between activity for them to be merged together. 0 is Never, Above 2 weeks (20160 mins) is Always.
// @SerialName("activityMergeTime") var activityMergeTime: Int?,
//
// // The language the user wants to see staff and character names in
// // @SerialName("staffNameLanguage") var staffNameLanguage: UserStaffNameLanguage?,
//
// // Whether the user only allow messages from users they follow
// @SerialName("restrictMessagesToFollowing") var restrictMessagesToFollowing: Boolean?,
// The list activity types the user has disabled from being created from list updates
// @SerialName("disabledListActivity") var disabledListActivity: List<ListActivityOption>?,
)
@Serializable
data class UserAvatar(
// The avatar of user at its largest size
@SerialName("large") var large: String?,
// The avatar of user at medium size
@SerialName("medium") var medium: String?,
)
@Serializable
data class UserStatisticTypes(
@SerialName("anime") var anime: UserStatistics?,
@SerialName("manga") var manga: UserStatistics?
)
@Serializable
data class UserStatistics(
//
@SerialName("count") var count: Int?,
@SerialName("meanScore") var meanScore: Float?,
@SerialName("standardDeviation") var standardDeviation: Float?,
@SerialName("minutesWatched") var minutesWatched: Int?,
@SerialName("episodesWatched") var episodesWatched: Int?,
@SerialName("chaptersRead") var chaptersRead: Int?,
@SerialName("volumesRead") var volumesRead: Int?,
// @SerialName("formats") var formats: List<UserFormatStatistic>?,
// @SerialName("statuses") var statuses: List<UserStatusStatistic>?,
// @SerialName("scores") var scores: List<UserScoreStatistic>?,
// @SerialName("lengths") var lengths: List<UserLengthStatistic>?,
// @SerialName("releaseYears") var releaseYears: List<UserReleaseYearStatistic>?,
// @SerialName("startYears") var startYears: List<UserStartYearStatistic>?,
// @SerialName("genres") var genres: List<UserGenreStatistic>?,
// @SerialName("tags") var tags: List<UserTagStatistic>?,
// @SerialName("countries") var countries: List<UserCountryStatistic>?,
// @SerialName("voiceActors") var voiceActors: List<UserVoiceActorStatistic>?,
// @SerialName("staff") var staff: List<UserStaffStatistic>?,
// @SerialName("studios") var studios: List<UserStudioStatistic>?,
)
@Serializable
data class Favourites(
// Favourite anime
@SerialName("anime") var anime: MediaConnection?,
// Favourite manga
@SerialName("manga") var manga: MediaConnection?,
// Favourite characters
@SerialName("characters") var characters: CharacterConnection?,
// Favourite staff
@SerialName("staff") var staff: StaffConnection?,
// Favourite studios
@SerialName("studios") var studios: StudioConnection?,
)
@Serializable
data class MediaListOptions(
// The score format the user is using for media lists
// @SerialName("scoreFormat") var scoreFormat: ScoreFormat?,
// The default order list rows should be displayed in
@SerialName("rowOrder") var rowOrder: String?,
// The user's anime list options
@SerialName("animeList") var animeList: MediaListTypeOptions?,
// The user's manga list options
@SerialName("mangaList") var mangaList: MediaListTypeOptions?,
)
@Serializable
data class MediaListTypeOptions(
// The order each list should be displayed in
@SerialName("sectionOrder") var sectionOrder: List<String>?,
// If the completed sections of the list should be separated by format
@SerialName("splitCompletedSectionByFormat") var splitCompletedSectionByFormat: Boolean?,
// The names of the user's custom lists
@SerialName("customLists") var customLists: List<String>?,
//
// // The names of the user's advanced scoring sections
// @SerialName("advancedScoring") var advancedScoring: List<String>?,
//
// // If advanced scoring is enabled
// @SerialName("advancedScoringEnabled") var advancedScoringEnabled: Boolean?,
)

View file

@ -0,0 +1,112 @@
package ani.dantotsu.connections.discord
import android.content.Context
import android.content.Intent
import android.widget.TextView
import androidx.core.content.edit
import ani.dantotsu.R
import ani.dantotsu.connections.discord.serializers.User
import ani.dantotsu.others.CustomBottomDialog
import ani.dantotsu.toast
import ani.dantotsu.tryWith
import ani.dantotsu.tryWithSuspend
import io.noties.markwon.Markwon
import io.noties.markwon.SoftBreakAddsNewLinePlugin
import kotlinx.coroutines.Dispatchers
import java.io.File
object Discord {
var token: String? = null
var userid: String? = null
var avatar: String? = null
private const val TOKEN = "discord_token"
fun getSavedToken(context: Context): Boolean {
val sharedPref = context.getSharedPreferences(
context.getString(R.string.preference_file_key),
Context.MODE_PRIVATE
)
token = sharedPref.getString(TOKEN, null)
return token != null
}
fun saveToken(context: Context, token: String) {
val sharedPref = context.getSharedPreferences(
context.getString(R.string.preference_file_key),
Context.MODE_PRIVATE
)
sharedPref.edit {
putString(TOKEN, token)
commit()
}
}
fun removeSavedToken(context: Context) {
val sharedPref = context.getSharedPreferences(
context.getString(R.string.preference_file_key),
Context.MODE_PRIVATE
)
sharedPref.edit {
remove(TOKEN)
commit()
}
tryWith(true) {
val dir = File(context.filesDir?.parentFile, "app_webview")
if (dir.deleteRecursively())
toast(context.getString(R.string.discord_logout_success))
}
}
private var rpc : RPC? = null
suspend fun getUserData() = tryWithSuspend(true) {
if(rpc==null) {
val rpc = RPC(token!!, Dispatchers.IO).also { rpc = it }
val user: User = rpc.getUserData()
userid = user.username
avatar = user.userAvatar()
rpc.close()
true
} else true
} ?: false
fun warning(context: Context) = CustomBottomDialog().apply {
title = context.getString(R.string.warning)
val md = context.getString(R.string.discord_warning)
addView(TextView(context).apply {
val markWon =
Markwon.builder(context).usePlugin(SoftBreakAddsNewLinePlugin.create()).build()
markWon.setMarkdown(this, md)
})
setNegativeButton(context.getString(R.string.cancel)) {
dismiss()
}
setPositiveButton(context.getString(R.string.login)) {
dismiss()
loginIntent(context)
}
}
private fun loginIntent(context: Context) {
val intent = Intent(context, Login::class.java)
context.startActivity(intent)
}
fun defaultRPC(): RPC? {
return token?.let {
RPC(it, Dispatchers.IO).apply {
applicationId = "1163925779692912771"
smallImage = RPC.Link(
"Dantotsu",
"mp:attachments/1163940221063278672/1163940262423298141/bitmap1024.png"
)
buttons.add(RPC.Link("Stream on Dantotsu", "https://github.com/rebelonion/Dantotsu/"))
}
}
}
}

View file

@ -0,0 +1,51 @@
package ani.dantotsu.connections.discord
import android.annotation.SuppressLint
import android.os.Bundle
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.appcompat.app.AppCompatActivity
import ani.dantotsu.R
import ani.dantotsu.connections.discord.Discord.saveToken
import ani.dantotsu.startMainActivity
class Login : AppCompatActivity() {
@SuppressLint("SetJavaScriptEnabled")
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_discord)
val webView = findViewById<WebView>(R.id.discordWebview)
webView.apply {
settings.javaScriptEnabled = true
settings.databaseEnabled = true
settings.domStorageEnabled = true
}
webView.webViewClient = object : WebViewClient() {
override fun onPageFinished(view: WebView?, url: String?) {
if (url != null && url.endsWith("/app")) {
webView.stopLoading()
webView.evaluateJavascript("""
(function() {
const wreq = webpackChunkdiscord_app.push([[Symbol()], {}, w => w])
webpackChunkdiscord_app.pop()
const token = Object.values(wreq.c).find(m => m.exports?.Z?.getToken).exports.Z.getToken();
return token;
})()
""".trimIndent()){
login(it.trim('"'))
}
}
}
}
webView.loadUrl("https://discord.com/login")
}
private fun login(token: String) {
finish()
saveToken(this, token)
startMainActivity(this@Login)
}
}

View file

@ -0,0 +1,238 @@
package ani.dantotsu.connections.discord
import ani.dantotsu.connections.discord.serializers.*
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Deferred
import kotlinx.coroutines.cancel
import kotlinx.coroutines.delay
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import kotlinx.serialization.Serializable
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import kotlinx.serialization.json.jsonPrimitive
import kotlinx.serialization.json.long
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.Response
import okhttp3.WebSocket
import okhttp3.WebSocketListener
import java.util.concurrent.TimeUnit.*
import kotlin.coroutines.CoroutineContext
import ani.dantotsu.client as app
@Suppress("MemberVisibilityCanBePrivate")
open class RPC(val token: String, val coroutineContext: CoroutineContext) {
private val json = Json {
encodeDefaults = true
allowStructuredMapKeys = true
ignoreUnknownKeys = true
}
private val client = OkHttpClient.Builder()
.connectTimeout(10, SECONDS)
.readTimeout(10, SECONDS)
.writeTimeout(10, SECONDS)
.build()
private val request = Request.Builder()
.url("wss://gateway.discord.gg/?encoding=json&v=10")
.build()
private var webSocket = client.newWebSocket(request, Listener())
var applicationId: String? = null
var type: Type? = null
var activityName: String? = null
var details: String? = null
var state: String? = null
var largeImage: Link? = null
var smallImage: Link? = null
var status: String? = null
var startTimestamp: Long? = null
var stopTimestamp: Long? = null
enum class Type {
PLAYING, STREAMING, LISTENING, WATCHING, COMPETING
}
var buttons = mutableListOf<Link>()
data class Link(val label: String, val url: String)
private suspend fun createPresence(): String {
return json.encodeToString(Presence.Response(
3,
Presence(
activities = listOf(
Activity(
name = activityName,
state = state,
details = details,
type = type?.ordinal,
timestamps = if (startTimestamp != null)
Activity.Timestamps(startTimestamp, stopTimestamp)
else null,
assets = Activity.Assets(
largeImage = largeImage?.url?.discordUrl(),
largeText = largeImage?.label,
smallImage = smallImage?.url?.discordUrl(),
smallText = smallImage?.label
),
buttons = buttons.map { it.label },
metadata = Activity.Metadata(
buttonUrls = buttons.map { it.url }
),
applicationId = applicationId,
)
),
afk = true,
since = startTimestamp,
status = status
)
))
}
@Serializable
data class KizzyApi(val id: String)
val api = "https://kizzy-api.vercel.app/image?url="
private suspend fun String.discordUrl(): String? {
if (startsWith("mp:")) return this
val json = app.get("$api$this").parsedSafe<KizzyApi>()
return json?.id
}
private fun sendIdentify() {
val response = Identity.Response(
op = 2,
d = Identity(
token = token,
properties = Identity.Properties(
os = "windows",
browser = "Chrome",
device = "disco"
),
compress = false,
intents = 0
)
)
webSocket.send(json.encodeToString(response))
}
fun send(block: RPC.() -> Unit) {
block.invoke(this)
send()
}
var started = false
var whenStarted: ((User) -> Unit)? = null
fun send() {
val send = {
CoroutineScope(coroutineContext).launch {
webSocket.send(createPresence())
}
}
if (!started) whenStarted = {
send.invoke()
whenStarted = null
}
else send.invoke()
}
fun close() {
webSocket.send(
json.encodeToString(
Presence.Response(
3,
Presence(status = "offline")
)
)
)
webSocket.close(4000, "Interrupt")
}
//I hate this, but couldn't find any better way to solve it
suspend fun getUserData(): User {
var user : User? = null
whenStarted = {
user = it
whenStarted = null
}
while (user == null) {
delay(100)
}
return user!!
}
var onReceiveUserData: ((User) -> Deferred<Unit>)? = null
inner class Listener : WebSocketListener() {
private var seq: Int? = null
private var heartbeatInterval: Long? = null
var scope = CoroutineScope(coroutineContext)
private fun sendHeartBeat() {
scope.cancel()
scope = CoroutineScope(coroutineContext)
scope.launch {
delay(heartbeatInterval!!)
webSocket.send("{\"op\":1, \"d\":$seq}")
}
}
override fun onMessage(webSocket: WebSocket, text: String) {
println("Message : $text")
val map = json.decodeFromString<Res>(text)
seq = map.s
when (map.op) {
10 -> {
map.d as JsonObject
heartbeatInterval = map.d["heartbeat_interval"]!!.jsonPrimitive.long
sendHeartBeat()
sendIdentify()
}
0 -> if (map.t == "READY") {
val user = json.decodeFromString<User.Response>(text).d.user
started = true
whenStarted?.invoke(user)
}
1 -> {
if (scope.isActive) scope.cancel()
webSocket.send("{\"op\":1, \"d\":$seq}")
}
11 -> sendHeartBeat()
7 -> webSocket.close(400, "Reconnect")
9 -> {
sendHeartBeat()
sendIdentify()
}
}
}
override fun onClosed(webSocket: WebSocket, code: Int, reason: String) {
println("Server Closed : $code $reason")
if (code == 4000) {
scope.cancel()
}
}
override fun onFailure(webSocket: WebSocket, t: Throwable, response: Response?) {
println("Failure : ${t.message}")
if (t.message != "Interrupt") {
this@RPC.webSocket = client.newWebSocket(request, Listener())
}
}
}
}

View file

@ -0,0 +1,44 @@
package ani.dantotsu.connections.discord.serializers
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class Activity (
@SerialName("application_id")
val applicationId: String? = null,
val name: String? = null,
val details: String? = null,
val state: String? = null,
val type: Int? = null,
val timestamps: Timestamps? = null,
val assets: Assets? = null,
val buttons: List<String>? = null,
val metadata: Metadata? = null
) {
@Serializable
data class Assets(
@SerialName("large_image")
val largeImage: String? = null,
@SerialName("large_text")
val largeText: String? = null,
@SerialName("small_image")
val smallImage: String? = null,
@SerialName("small_text")
val smallText: String? = null
)
@Serializable
data class Metadata(
@SerialName("button_urls")
val buttonUrls: List<String>
)
@Serializable
data class Timestamps(
val start: Long? = null,
val stop: Long? = null
)
}

View file

@ -0,0 +1,31 @@
package ani.dantotsu.connections.discord.serializers
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class Identity(
val token: String,
val properties: Properties,
val compress: Boolean,
val intents: Long
) {
@Serializable
data class Response (
val op: Long,
val d: Identity
)
@Serializable
data class Properties (
@SerialName("\$os")
val os: String,
@SerialName("\$browser")
val browser: String,
@SerialName("\$device")
val device: String
)
}

View file

@ -0,0 +1,19 @@
package ani.dantotsu.connections.discord.serializers
import kotlinx.serialization.Serializable
@Serializable
data class Presence (
val activities: List<Activity> = listOf(),
val afk: Boolean = true,
val since: Long? = null,
val status: String? = null
){
@Serializable
data class Response (
val op: Long,
val d: Presence
)
}

View file

@ -0,0 +1,12 @@
package ani.dantotsu.connections.discord.serializers
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement
@Serializable
data class Res(
val t: String?,
val s: Int?,
val op: Int,
val d: JsonElement
)

View file

@ -0,0 +1,76 @@
package ani.dantotsu.connections.discord.serializers
import kotlinx.serialization.*
import kotlinx.serialization.descriptors.*
import kotlinx.serialization.encoding.*
import kotlinx.serialization.json.*
@Serializable
data class User (
val verified: Boolean,
val username: String,
@SerialName("purchased_flags")
val purchasedFlags: Long,
@SerialName("public_flags")
val publicFlags: Long,
val pronouns: String,
@SerialName("premium_type")
val premiumType: Long,
val premium: Boolean,
val phone: String,
@SerialName("nsfw_allowed")
val nsfwAllowed: Boolean,
val mobile: Boolean,
@SerialName("mfa_enabled")
val mfaEnabled: Boolean,
val id: String,
@SerialName("global_name")
val globalName: String,
val flags: Long,
val email: String,
val discriminator: String,
val desktop: Boolean,
val bio: String,
@SerialName("banner_color")
val bannerColor: String,
val banner: JsonElement? = null,
@SerialName("avatar_decoration")
val avatarDecoration: JsonElement? = null,
val avatar: String,
@SerialName("accent_color")
val accentColor: Long
) {
@Serializable
data class Response(
val t: String,
val s: Long,
val op: Long,
val d: D
) {
@Serializable
data class D(
val v: Long,
val user: User,
)
}
fun userAvatar():String{
return "https://cdn.discordapp.com/avatars/$id/$avatar.png"
}
}

View file

@ -0,0 +1,52 @@
package ani.dantotsu.connections.mal
import android.net.Uri
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.lifecycle.lifecycleScope
import ani.dantotsu.*
import ani.dantotsu.connections.mal.MAL.clientId
import ani.dantotsu.connections.mal.MAL.saveResponse
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
class Login : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
try {
val data: Uri = intent?.data
?: throw Exception(getString(R.string.mal_login_uri_not_found))
val codeChallenge: String = loadData("malCodeChallenge", this)
?: throw Exception(getString(R.string.mal_login_code_challenge_not_found))
val code = data.getQueryParameter("code")
?: throw Exception(getString(R.string.mal_login_code_not_present))
snackString(getString(R.string.logging_in_mal))
lifecycleScope.launch(Dispatchers.IO) {
tryWithSuspend(true) {
val res = client.post(
"https://myanimelist.net/v1/oauth2/token",
data = mapOf(
"client_id" to clientId,
"code" to code,
"code_verifier" to codeChallenge,
"grant_type" to "authorization_code"
)
).parsed<MAL.ResponseToken>()
saveResponse(res)
MAL.token = res.accessToken
snackString(getString(R.string.getting_user_data))
MAL.query.getUserData()
launch(Dispatchers.Main) {
startMainActivity(this@Login)
}
}
}
}
catch (e:Exception){
logError(e,snackbar = false)
startMainActivity(this)
}
}
}

View file

@ -0,0 +1,99 @@
package ani.dantotsu.connections.mal
import android.content.ActivityNotFoundException
import android.content.Context
import android.net.Uri
import android.util.Base64
import androidx.browser.customtabs.CustomTabsIntent
import androidx.fragment.app.FragmentActivity
import ani.dantotsu.*
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import java.io.File
import java.security.SecureRandom
object MAL {
val query: MALQueries = MALQueries()
const val clientId = "86b35cf02205a0303da3aaea1c9e33f3"
var username: String? = null
var avatar: String? = null
var token: String? = null
var userid: Int? = null
fun loginIntent(context: Context) {
val codeVerifierBytes = ByteArray(96)
SecureRandom().nextBytes(codeVerifierBytes)
val codeChallenge = Base64.encodeToString(codeVerifierBytes, Base64.DEFAULT).trimEnd('=')
.replace("+", "-")
.replace("/", "_")
.replace("\n", "")
saveData("malCodeChallenge", codeChallenge, context)
val request =
"https://myanimelist.net/v1/oauth2/authorize?response_type=code&client_id=$clientId&code_challenge=$codeChallenge"
try {
CustomTabsIntent.Builder().build().launchUrl(
context,
Uri.parse(request)
)
} catch (e: ActivityNotFoundException) {
openLinkInBrowser(request)
}
}
private const val MAL_TOKEN = "malToken"
private suspend fun refreshToken(): ResponseToken? {
return tryWithSuspend {
val token = loadData<ResponseToken>(MAL_TOKEN)
?: throw Exception(currContext()?.getString(R.string.refresh_token_load_failed))
val res = client.post(
"https://myanimelist.net/v1/oauth2/token",
data = mapOf(
"client_id" to clientId,
"grant_type" to "refresh_token",
"refresh_token" to token.refreshToken
)
).parsed<ResponseToken>()
saveResponse(res)
return@tryWithSuspend res
}
}
suspend fun getSavedToken(context: FragmentActivity): Boolean {
return tryWithSuspend(false) {
var res: ResponseToken = loadData(MAL_TOKEN, context)
?: return@tryWithSuspend false
if (System.currentTimeMillis() > res.expiresIn)
res = refreshToken()
?: throw Exception(currContext()?.getString(R.string.refreshing_token_failed))
token = res.accessToken
return@tryWithSuspend true
} ?: false
}
fun removeSavedToken(context: Context) {
token = null
username = null
userid = null
avatar = null
if (MAL_TOKEN in context.fileList()) {
File(context.filesDir, MAL_TOKEN).delete()
}
}
fun saveResponse(res: ResponseToken) {
res.expiresIn += System.currentTimeMillis()
saveData(MAL_TOKEN, res)
}
@Serializable
data class ResponseToken(
@SerialName("token_type") val tokenType: String,
@SerialName("expires_in") var expiresIn: Long,
@SerialName("access_token") val accessToken: String,
@SerialName("refresh_token") val refreshToken: String,
): java.io.Serializable
}

View file

@ -0,0 +1,90 @@
package ani.dantotsu.connections.mal
import ani.dantotsu.connections.anilist.api.FuzzyDate
import ani.dantotsu.client
import ani.dantotsu.tryWithSuspend
import kotlinx.serialization.Serializable
class MALQueries {
private val apiUrl = "https://api.myanimelist.net/v2"
private val authHeader: Map<String, String>?
get() {
return mapOf("Authorization" to "Bearer ${MAL.token ?: return null}")
}
@Serializable
data class MalUser(
val id: Int,
val name: String,
val picture: String?,
)
suspend fun getUserData(): Boolean {
val res = tryWithSuspend {
client.get(
"$apiUrl/users/@me",
authHeader ?: return@tryWithSuspend null
).parsed<MalUser>()
} ?: return false
MAL.userid = res.id
MAL.username = res.name
MAL.avatar = res.picture
return true
}
suspend fun editList(
idMAL: Int?,
isAnime: Boolean,
progress: Int?,
score: Int?,
status: String,
rewatch: Int? = null,
start: FuzzyDate? = null,
end: FuzzyDate? = null
) {
if(idMAL==null) return
val data = mutableMapOf("status" to convertStatus(isAnime, status))
if (progress != null)
data[if (isAnime) "num_watched_episodes" else "num_chapters_read"] = progress.toString()
data[if (isAnime) "is_rewatching" else "is_rereading"] = (status == "REPEATING").toString()
if (score != null)
data["score"] = score.div(10).toString()
if(rewatch!=null)
data[if(isAnime) "num_times_rewatched" else "num_times_reread"] = rewatch.toString()
if(start!=null)
data["start_date"] = start.toMALString()
if(end!=null)
data["finish_date"] = end.toMALString()
tryWithSuspend {
client.put(
"$apiUrl/${if (isAnime) "anime" else "manga"}/$idMAL/my_list_status",
authHeader ?: return@tryWithSuspend null,
data = data,
)
}
}
suspend fun deleteList(isAnime: Boolean, idMAL: Int?){
if(idMAL==null) return
tryWithSuspend {
client.delete(
"$apiUrl/${if (isAnime) "anime" else "manga"}/$idMAL/my_list_status",
authHeader ?: return@tryWithSuspend null
)
}
}
private fun convertStatus(isAnime: Boolean, status: String): String {
return when (status) {
"PLANNING" -> if (isAnime) "plan_to_watch" else "plan_to_read"
"COMPLETED" -> "completed"
"PAUSED" -> "on_hold"
"DROPPED" -> "dropped"
"CURRENT" -> if (isAnime) "watching" else "reading"
else -> if (isAnime) "watching" else "reading"
}
}
}