mirror of
https://github.com/aniyomiorg/aniyomi.git
synced 2024-11-28 09:15:12 +03:00
Merge anilist backend
This commit is contained in:
parent
08e26aa30d
commit
cb92143613
10 changed files with 440 additions and 14 deletions
|
@ -1,16 +1,21 @@
|
|||
package eu.kanade.tachiyomi.data.mangasync
|
||||
|
||||
import android.content.Context
|
||||
import eu.kanade.tachiyomi.data.mangasync.anilist.Anilist
|
||||
import eu.kanade.tachiyomi.data.mangasync.myanimelist.MyAnimeList
|
||||
|
||||
class MangaSyncManager(private val context: Context) {
|
||||
|
||||
companion object {
|
||||
const val MYANIMELIST = 1
|
||||
const val ANILIST = 2
|
||||
}
|
||||
|
||||
val myAnimeList = MyAnimeList(context, MYANIMELIST)
|
||||
|
||||
val aniList = Anilist(context, ANILIST)
|
||||
|
||||
// TODO enable anilist
|
||||
val services = listOf(myAnimeList)
|
||||
|
||||
fun getService(id: Int) = services.find { it.id == id }
|
||||
|
|
|
@ -0,0 +1,132 @@
|
|||
package eu.kanade.tachiyomi.data.mangasync.anilist
|
||||
|
||||
import android.content.Context
|
||||
import eu.kanade.tachiyomi.R
|
||||
import eu.kanade.tachiyomi.data.database.models.MangaSync
|
||||
import eu.kanade.tachiyomi.data.mangasync.MangaSyncService
|
||||
import rx.Completable
|
||||
import rx.Observable
|
||||
import timber.log.Timber
|
||||
|
||||
class Anilist(private val context: Context, id: Int) : MangaSyncService(context, id) {
|
||||
|
||||
companion object {
|
||||
const val READING = 1
|
||||
const val COMPLETED = 2
|
||||
const val ON_HOLD = 3
|
||||
const val DROPPED = 4
|
||||
const val PLAN_TO_READ = 5
|
||||
|
||||
const val DEFAULT_STATUS = READING
|
||||
const val DEFAULT_SCORE = 0
|
||||
}
|
||||
|
||||
override val name = "AniList"
|
||||
|
||||
private val interceptor by lazy { AnilistInterceptor(getPassword()) }
|
||||
|
||||
private val api by lazy {
|
||||
AnilistApi.createService(networkService.client.newBuilder()
|
||||
.addInterceptor(interceptor)
|
||||
.build())
|
||||
}
|
||||
|
||||
override fun login(username: String, password: String) = login(password)
|
||||
|
||||
fun login(authCode: String): Completable {
|
||||
// Create a new api with the default client to avoid request interceptions.
|
||||
return AnilistApi.createService(client)
|
||||
// Request the access token from the API with the authorization code.
|
||||
.requestAccessToken(authCode)
|
||||
// Save the token in the interceptor.
|
||||
.doOnNext { interceptor.setAuth(it) }
|
||||
// Obtain the authenticated user from the API.
|
||||
.zipWith(api.getCurrentUser().map { it["id"].toString() })
|
||||
{ oauth, user -> Pair(user, oauth.refresh_token!!) }
|
||||
// Save service credentials (username and refresh token).
|
||||
.doOnNext { saveCredentials(it.first, it.second) }
|
||||
// Logout on any error.
|
||||
.doOnError { logout() }
|
||||
.toCompletable()
|
||||
}
|
||||
|
||||
override fun logout() {
|
||||
super.logout()
|
||||
interceptor.setAuth(null)
|
||||
}
|
||||
|
||||
fun search(query: String): Observable<List<MangaSync>> {
|
||||
return api.search(query, 1)
|
||||
.flatMap { Observable.from(it) }
|
||||
.filter { it.type != "Novel" }
|
||||
.map { it.toMangaSync() }
|
||||
.toList()
|
||||
}
|
||||
|
||||
fun getList(): Observable<List<MangaSync>> {
|
||||
return api.getList(getUsername())
|
||||
.flatMap { Observable.from(it.flatten()) }
|
||||
.map { it.toMangaSync() }
|
||||
.toList()
|
||||
}
|
||||
|
||||
override fun add(manga: MangaSync): Observable<MangaSync> {
|
||||
return api.addManga(manga.remote_id, manga.last_chapter_read, manga.getAnilistStatus(),
|
||||
manga.score.toInt())
|
||||
.doOnNext { it.body().close() }
|
||||
.doOnNext { if (!it.isSuccessful) throw Exception("Could not add manga") }
|
||||
.doOnError { Timber.e(it, it.message) }
|
||||
.map { manga }
|
||||
}
|
||||
|
||||
override fun update(manga: MangaSync): Observable<MangaSync> {
|
||||
if (manga.total_chapters != 0 && manga.last_chapter_read == manga.total_chapters) {
|
||||
manga.status = COMPLETED
|
||||
}
|
||||
return api.updateManga(manga.remote_id, manga.last_chapter_read, manga.getAnilistStatus(),
|
||||
manga.score.toInt())
|
||||
.doOnNext { it.body().close() }
|
||||
.doOnNext { if (!it.isSuccessful) throw Exception("Could not update manga") }
|
||||
.doOnError { Timber.e(it, it.message) }
|
||||
.map { manga }
|
||||
}
|
||||
|
||||
override fun bind(manga: MangaSync): Observable<MangaSync> {
|
||||
return getList()
|
||||
.flatMap { userlist ->
|
||||
manga.sync_id = id
|
||||
val mangaFromList = userlist.find { it.remote_id == manga.remote_id }
|
||||
if (mangaFromList != null) {
|
||||
manga.copyPersonalFrom(mangaFromList)
|
||||
update(manga)
|
||||
} else {
|
||||
// Set default fields if it's not found in the list
|
||||
manga.score = DEFAULT_SCORE.toFloat()
|
||||
manga.status = DEFAULT_STATUS
|
||||
add(manga)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
override fun getStatus(status: Int): String = with(context) {
|
||||
when (status) {
|
||||
READING -> getString(R.string.reading)
|
||||
COMPLETED -> getString(R.string.completed)
|
||||
ON_HOLD -> getString(R.string.on_hold)
|
||||
DROPPED -> getString(R.string.dropped)
|
||||
PLAN_TO_READ -> getString(R.string.plan_to_read)
|
||||
else -> ""
|
||||
}
|
||||
}
|
||||
|
||||
private fun MangaSync.getAnilistStatus() = when (status) {
|
||||
READING -> "reading"
|
||||
COMPLETED -> "completed"
|
||||
ON_HOLD -> "on-hold"
|
||||
DROPPED -> "dropped"
|
||||
PLAN_TO_READ -> "plan to read"
|
||||
else -> throw NotImplementedError("Unknown status")
|
||||
}
|
||||
|
||||
}
|
||||
|
|
@ -0,0 +1,89 @@
|
|||
package eu.kanade.tachiyomi.data.mangasync.anilist
|
||||
|
||||
import android.net.Uri
|
||||
import com.google.gson.JsonObject
|
||||
import eu.kanade.tachiyomi.data.mangasync.anilist.model.ALManga
|
||||
import eu.kanade.tachiyomi.data.mangasync.anilist.model.ALUserLists
|
||||
import eu.kanade.tachiyomi.data.mangasync.anilist.model.OAuth
|
||||
import eu.kanade.tachiyomi.data.network.POST
|
||||
import okhttp3.FormBody
|
||||
import okhttp3.OkHttpClient
|
||||
import okhttp3.ResponseBody
|
||||
import retrofit2.Response
|
||||
import retrofit2.Retrofit
|
||||
import retrofit2.adapter.rxjava.RxJavaCallAdapterFactory
|
||||
import retrofit2.converter.gson.GsonConverterFactory
|
||||
import retrofit2.http.*
|
||||
import rx.Observable
|
||||
|
||||
interface AnilistApi {
|
||||
|
||||
companion object {
|
||||
private const val clientId = "tachiyomi-hrtje"
|
||||
private const val clientSecret = "nlGB5OmgE9YWq5dr3gIDbTQV0C"
|
||||
private const val clientUrl = "tachiyomi://anilist-auth"
|
||||
private const val baseUrl = "https://anilist.co/api/"
|
||||
|
||||
fun authUrl() = Uri.parse("${baseUrl}auth/authorize").buildUpon()
|
||||
.appendQueryParameter("grant_type", "authorization_code")
|
||||
.appendQueryParameter("client_id", clientId)
|
||||
.appendQueryParameter("redirect_uri", clientUrl)
|
||||
.appendQueryParameter("response_type", "code")
|
||||
.build()
|
||||
|
||||
fun refreshTokenRequest(token: String) = POST("${baseUrl}auth/access_token",
|
||||
body = FormBody.Builder()
|
||||
.add("grant_type", "refresh_token")
|
||||
.add("client_id", clientId)
|
||||
.add("client_secret", clientSecret)
|
||||
.add("refresh_token", token)
|
||||
.build())
|
||||
|
||||
fun createService(client: OkHttpClient) = Retrofit.Builder()
|
||||
.baseUrl(baseUrl)
|
||||
.client(client)
|
||||
.addConverterFactory(GsonConverterFactory.create())
|
||||
.addCallAdapterFactory(RxJavaCallAdapterFactory.create())
|
||||
.build()
|
||||
.create(AnilistApi::class.java)
|
||||
|
||||
}
|
||||
|
||||
@FormUrlEncoded
|
||||
@POST("auth/access_token")
|
||||
fun requestAccessToken(
|
||||
@Field("code") code: String,
|
||||
@Field("grant_type") grant_type: String = "authorization_code",
|
||||
@Field("client_id") client_id: String = clientId,
|
||||
@Field("client_secret") client_secret: String = clientSecret,
|
||||
@Field("redirect_uri") redirect_uri: String = clientUrl)
|
||||
: Observable<OAuth>
|
||||
|
||||
@GET("user")
|
||||
fun getCurrentUser(): Observable<JsonObject>
|
||||
|
||||
@GET("manga/search/{query}")
|
||||
fun search(@Path("query") query: String, @Query("page") page: Int): Observable<List<ALManga>>
|
||||
|
||||
@GET("user/{username}/mangalist")
|
||||
fun getList(@Path("username") username: String): Observable<ALUserLists>
|
||||
|
||||
@FormUrlEncoded
|
||||
@PUT("mangalist")
|
||||
fun addManga(
|
||||
@Field("id") id: Int,
|
||||
@Field("chapters_read") chapters_read: Int,
|
||||
@Field("list_status") list_status: String,
|
||||
@Field("score_raw") score_raw: Int)
|
||||
: Observable<Response<ResponseBody>>
|
||||
|
||||
@FormUrlEncoded
|
||||
@PUT("mangalist")
|
||||
fun updateManga(
|
||||
@Field("id") id: Int,
|
||||
@Field("chapters_read") chapters_read: Int,
|
||||
@Field("list_status") list_status: String,
|
||||
@Field("score_raw") score_raw: Int)
|
||||
: Observable<Response<ResponseBody>>
|
||||
|
||||
}
|
|
@ -0,0 +1,61 @@
|
|||
package eu.kanade.tachiyomi.data.mangasync.anilist
|
||||
|
||||
import com.google.gson.Gson
|
||||
import eu.kanade.tachiyomi.data.mangasync.anilist.model.OAuth
|
||||
import okhttp3.Interceptor
|
||||
import okhttp3.Response
|
||||
|
||||
class AnilistInterceptor(private var refreshToken: String?) : Interceptor {
|
||||
|
||||
/**
|
||||
* OAuth object used for authenticated requests.
|
||||
*
|
||||
* Anilist returns the date without milliseconds. We fix that and make the token expire 1 minute
|
||||
* before its original expiration date.
|
||||
*/
|
||||
private var oauth: OAuth? = null
|
||||
set(value) {
|
||||
field = value?.copy(expires = value.expires * 1000 - 60 * 1000)
|
||||
}
|
||||
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
val originalRequest = chain.request()
|
||||
|
||||
if (refreshToken.isNullOrEmpty()) {
|
||||
throw Exception("Not authenticated with Anilist")
|
||||
}
|
||||
|
||||
// Refresh access token if null or expired.
|
||||
if (oauth == null || oauth!!.isExpired()) {
|
||||
val response = chain.proceed(AnilistApi.refreshTokenRequest(refreshToken!!))
|
||||
oauth = if (response.isSuccessful) {
|
||||
Gson().fromJson(response.body().string(), OAuth::class.java)
|
||||
} else {
|
||||
response.close()
|
||||
null
|
||||
}
|
||||
}
|
||||
|
||||
// Throw on null auth.
|
||||
if (oauth == null) {
|
||||
throw Exception("Access token wasn't refreshed")
|
||||
}
|
||||
|
||||
// Add the authorization header to the original request.
|
||||
val authRequest = originalRequest.newBuilder()
|
||||
.addHeader("Authorization", "Bearer ${oauth!!.access_token}")
|
||||
.build()
|
||||
|
||||
return chain.proceed(authRequest)
|
||||
}
|
||||
|
||||
/**
|
||||
* Called when the user authenticates with Anilist for the first time. Sets the refresh token
|
||||
* and the oauth object.
|
||||
*/
|
||||
fun setAuth(oauth: OAuth?) {
|
||||
refreshToken = oauth?.refresh_token
|
||||
this.oauth = oauth
|
||||
}
|
||||
|
||||
}
|
|
@ -0,0 +1,17 @@
|
|||
package eu.kanade.tachiyomi.data.mangasync.anilist.model
|
||||
|
||||
import eu.kanade.tachiyomi.data.database.models.MangaSync
|
||||
import eu.kanade.tachiyomi.data.mangasync.MangaSyncManager
|
||||
|
||||
data class ALManga(
|
||||
val id: Int,
|
||||
val title_romaji: String,
|
||||
val type: String,
|
||||
val total_chapters: Int) {
|
||||
|
||||
fun toMangaSync() = MangaSync.create(MangaSyncManager.ANILIST).apply {
|
||||
remote_id = this@ALManga.id
|
||||
title = title_romaji
|
||||
total_chapters = this@ALManga.total_chapters
|
||||
}
|
||||
}
|
|
@ -0,0 +1,6 @@
|
|||
package eu.kanade.tachiyomi.data.mangasync.anilist.model
|
||||
|
||||
data class ALUserLists(val lists: Map<String, List<ALUserManga>>) {
|
||||
|
||||
fun flatten() = lists.values.flatten()
|
||||
}
|
|
@ -0,0 +1,29 @@
|
|||
package eu.kanade.tachiyomi.data.mangasync.anilist.model
|
||||
|
||||
import eu.kanade.tachiyomi.data.database.models.MangaSync
|
||||
import eu.kanade.tachiyomi.data.mangasync.MangaSyncManager
|
||||
import eu.kanade.tachiyomi.data.mangasync.anilist.Anilist
|
||||
|
||||
data class ALUserManga(
|
||||
val id: Int,
|
||||
val list_status: String,
|
||||
val score_raw: Int,
|
||||
val chapters_read: Int,
|
||||
val manga: ALManga) {
|
||||
|
||||
fun toMangaSync() = MangaSync.create(MangaSyncManager.ANILIST).apply {
|
||||
remote_id = manga.id
|
||||
status = getMangaSyncStatus()
|
||||
score = score_raw.toFloat()
|
||||
last_chapter_read = chapters_read
|
||||
}
|
||||
|
||||
fun getMangaSyncStatus() = when (list_status) {
|
||||
"reading" -> Anilist.READING
|
||||
"completed" -> Anilist.COMPLETED
|
||||
"on-hold" -> Anilist.ON_HOLD
|
||||
"dropped" -> Anilist.DROPPED
|
||||
"plan to read" -> Anilist.PLAN_TO_READ
|
||||
else -> throw NotImplementedError("Unknown status")
|
||||
}
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
package eu.kanade.tachiyomi.data.mangasync.anilist.model
|
||||
|
||||
data class OAuth(
|
||||
val access_token: String,
|
||||
val token_type: String,
|
||||
val expires: Long,
|
||||
val expires_in: Long,
|
||||
val refresh_token: String?) {
|
||||
|
||||
fun isExpired() = System.currentTimeMillis() > expires
|
||||
}
|
|
@ -0,0 +1,49 @@
|
|||
package eu.kanade.tachiyomi.ui.setting
|
||||
|
||||
import android.content.Intent
|
||||
import android.os.Bundle
|
||||
import android.support.v7.app.AppCompatActivity
|
||||
import android.view.Gravity.CENTER
|
||||
import android.view.ViewGroup.LayoutParams.WRAP_CONTENT
|
||||
import android.widget.FrameLayout
|
||||
import android.widget.ProgressBar
|
||||
import eu.kanade.tachiyomi.data.mangasync.MangaSyncManager
|
||||
import rx.android.schedulers.AndroidSchedulers
|
||||
import rx.schedulers.Schedulers
|
||||
import uy.kohesive.injekt.injectLazy
|
||||
|
||||
class AnilistLoginActivity : AppCompatActivity() {
|
||||
|
||||
private val syncManager: MangaSyncManager by injectLazy()
|
||||
|
||||
override fun onCreate(savedState: Bundle?) {
|
||||
super.onCreate(savedState)
|
||||
|
||||
val view = ProgressBar(this)
|
||||
setContentView(view, FrameLayout.LayoutParams(WRAP_CONTENT, WRAP_CONTENT, CENTER))
|
||||
|
||||
val code = intent.data?.getQueryParameter("code")
|
||||
if (code != null) {
|
||||
syncManager.aniList.login(code)
|
||||
.subscribeOn(Schedulers.io())
|
||||
.observeOn(AndroidSchedulers.mainThread())
|
||||
.subscribe({
|
||||
returnToSettings()
|
||||
}, { error ->
|
||||
returnToSettings()
|
||||
})
|
||||
} else {
|
||||
syncManager.aniList.logout()
|
||||
returnToSettings()
|
||||
}
|
||||
}
|
||||
|
||||
private fun returnToSettings() {
|
||||
finish()
|
||||
|
||||
val intent = Intent(this, SettingsActivity::class.java)
|
||||
intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP)
|
||||
startActivity(intent)
|
||||
}
|
||||
|
||||
}
|
|
@ -6,6 +6,7 @@ import android.support.v7.preference.PreferenceCategory
|
|||
import android.support.v7.preference.XpPreferenceFragment
|
||||
import android.view.View
|
||||
import eu.kanade.tachiyomi.data.mangasync.MangaSyncManager
|
||||
import eu.kanade.tachiyomi.data.mangasync.MangaSyncService
|
||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||
import eu.kanade.tachiyomi.widget.preference.LoginPreference
|
||||
import eu.kanade.tachiyomi.widget.preference.MangaSyncLoginDialog
|
||||
|
@ -32,30 +33,56 @@ class SettingsSyncFragment : SettingsFragment() {
|
|||
override fun onViewCreated(view: View, savedState: Bundle?) {
|
||||
super.onViewCreated(view, savedState)
|
||||
|
||||
val themedContext = preferenceManager.context
|
||||
registerService(syncManager.myAnimeList)
|
||||
|
||||
for (sync in syncManager.services) {
|
||||
val pref = LoginPreference(themedContext).apply {
|
||||
key = preferences.keys.syncUsername(sync.id)
|
||||
title = sync.name
|
||||
// registerService(syncManager.aniList) {
|
||||
// val intent = CustomTabsIntent.Builder()
|
||||
// .setToolbarColor(activity.theme.getResourceColor(R.attr.colorPrimary))
|
||||
// .build()
|
||||
// intent.intent.addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY)
|
||||
// intent.launchUrl(activity, AnilistApi.authUrl())
|
||||
// }
|
||||
}
|
||||
|
||||
setOnPreferenceClickListener {
|
||||
val fragment = MangaSyncLoginDialog.newInstance(sync)
|
||||
fragment.setTargetFragment(this@SettingsSyncFragment, SYNC_CHANGE_REQUEST)
|
||||
fragment.show(fragmentManager, null)
|
||||
true
|
||||
}
|
||||
private fun <T : MangaSyncService> registerService(
|
||||
service: T,
|
||||
onPreferenceClick: (T) -> Unit = defaultOnPreferenceClick) {
|
||||
|
||||
LoginPreference(preferenceManager.context).apply {
|
||||
key = preferences.keys.syncUsername(service.id)
|
||||
title = service.name
|
||||
|
||||
setOnPreferenceClickListener {
|
||||
onPreferenceClick(service)
|
||||
true
|
||||
}
|
||||
|
||||
syncCategory.addPreference(pref)
|
||||
syncCategory.addPreference(this)
|
||||
}
|
||||
}
|
||||
|
||||
private val defaultOnPreferenceClick: (MangaSyncService) -> Unit
|
||||
get() = {
|
||||
val fragment = MangaSyncLoginDialog.newInstance(it)
|
||||
fragment.setTargetFragment(this, SYNC_CHANGE_REQUEST)
|
||||
fragment.show(fragmentManager, null)
|
||||
}
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
// Manually refresh anilist holder
|
||||
// updatePreference(syncManager.aniList.id)
|
||||
}
|
||||
|
||||
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
|
||||
if (requestCode == SYNC_CHANGE_REQUEST) {
|
||||
val pref = findPreference(preferences.keys.syncUsername(resultCode)) as? LoginPreference
|
||||
pref?.notifyChanged()
|
||||
updatePreference(resultCode)
|
||||
}
|
||||
}
|
||||
|
||||
private fun updatePreference(id: Int) {
|
||||
val pref = findPreference(preferences.keys.syncUsername(id)) as? LoginPreference
|
||||
pref?.notifyChanged()
|
||||
}
|
||||
|
||||
}
|
||||
|
|
Loading…
Reference in a new issue