浏览代码

Merge anilist backend

len 8 年之前
父节点
当前提交
cb92143613

+ 5 - 0
app/src/main/java/eu/kanade/tachiyomi/data/mangasync/MangaSyncManager.kt

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

+ 132 - 0
app/src/main/java/eu/kanade/tachiyomi/data/mangasync/anilist/Anilist.kt

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

+ 89 - 0
app/src/main/java/eu/kanade/tachiyomi/data/mangasync/anilist/AnilistApi.kt

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

+ 61 - 0
app/src/main/java/eu/kanade/tachiyomi/data/mangasync/anilist/AnilistInterceptor.kt

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

+ 17 - 0
app/src/main/java/eu/kanade/tachiyomi/data/mangasync/anilist/model/ALManga.kt

@@ -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 = [email protected]
+        title = title_romaji
+        total_chapters = [email protected]_chapters
+    }
+}

+ 6 - 0
app/src/main/java/eu/kanade/tachiyomi/data/mangasync/anilist/model/ALUserLists.kt

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

+ 29 - 0
app/src/main/java/eu/kanade/tachiyomi/data/mangasync/anilist/model/ALUserManga.kt

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

+ 11 - 0
app/src/main/java/eu/kanade/tachiyomi/data/mangasync/anilist/model/OAuth.kt

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

+ 49 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/setting/AnilistLoginActivity.kt

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

+ 41 - 14
app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsSyncFragment.kt

@@ -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())
+//        }
+    }
+
+    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 {
-                    val fragment = MangaSyncLoginDialog.newInstance(sync)
-                    fragment.setTargetFragment(this@SettingsSyncFragment, SYNC_CHANGE_REQUEST)
-                    fragment.show(fragmentManager, null)
-                    true
-                }
+            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()
+    }
+
 }