mirror of
				https://github.com/mihonapp/mihon.git
				synced 2025-10-30 22:07:57 +01:00 
			
		
		
		
	Merge anilist backend
This commit is contained in:
		| @@ -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() | ||||
|     } | ||||
|  | ||||
| } | ||||
|   | ||||
		Reference in New Issue
	
	Block a user