From 3abae1cc75837e9b64f5f1577da827daa17f00ce Mon Sep 17 00:00:00 2001 From: fei long Date: Tue, 23 Jul 2019 18:35:38 +0800 Subject: [PATCH] Add chinese track website "bangumi" (#2032) * copy from shikimori and change parmater * add login activity * fix * login sucess * search * add... * auth fix * save status * revert shikimori * fix oauth error * add bangumi info * update read chapter index * refersh token * remove outdate file * drop comment * change icon * drop search result which type not comic * fix bind logic * set status * add ep status * format code * disable cache for `collection` api --- app/src/main/AndroidManifest.xml | 14 ++ .../tachiyomi/data/track/TrackManager.kt | 6 +- .../tachiyomi/data/track/bangumi/Avatar.kt | 7 + .../tachiyomi/data/track/bangumi/Bangumi.kt | 144 ++++++++++++ .../data/track/bangumi/BangumiApi.kt | 208 ++++++++++++++++++ .../data/track/bangumi/BangumiInterceptor.kt | 61 +++++ .../data/track/bangumi/BangumiModels.kt | 22 ++ .../data/track/bangumi/Collection.kt | 13 ++ .../tachiyomi/data/track/bangumi/OAuth.kt | 16 ++ .../tachiyomi/data/track/bangumi/Status.kt | 7 + .../tachiyomi/data/track/bangumi/User.kt | 11 + .../ui/setting/BangumiLoginActivity.kt | 50 +++++ .../ui/setting/SettingsTrackingController.kt | 10 + app/src/main/res/drawable-xxxhdpi/bangumi.png | Bin 0 -> 6388 bytes 14 files changed, 568 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/Avatar.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/Bangumi.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/BangumiApi.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/BangumiInterceptor.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/BangumiModels.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/Collection.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/OAuth.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/Status.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/User.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/ui/setting/BangumiLoginActivity.kt create mode 100644 app/src/main/res/drawable-xxxhdpi/bangumi.png diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index eff4c70578..e13d89ba7c 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -75,6 +75,20 @@ android:scheme="tachiyomi" /> + + + + + + + + + + { + return IntRange(0, 10).map(Int::toString) + } + + override fun displayScore(track: Track): String { + return track.score.toInt().toString() + } + + override fun add(track: Track): Observable { + return api.addLibManga(track) + } + + override fun update(track: Track): Observable { + if (track.total_chapters != 0 && track.last_chapter_read == track.total_chapters) { + track.status = COMPLETED + } + return api.updateLibManga(track) + } + + override fun bind(track: Track): Observable { + return api.statusLibManga(track) + .flatMap { + api.findLibManga(track).flatMap { remoteTrack -> + if (remoteTrack != null && it != null) { + track.copyPersonalFrom(remoteTrack) + track.library_id = remoteTrack.library_id + track.status = remoteTrack.status + track.last_chapter_read = remoteTrack.last_chapter_read + update(track) + } else { + // Set default fields if it's not found in the list + track.score = DEFAULT_SCORE.toFloat() + track.status = DEFAULT_STATUS + add(track) + update(track) + } + } + } + } + + override fun search(query: String): Observable> { + return api.search(query) + } + + override fun refresh(track: Track): Observable { + return api.statusLibManga(track) + .flatMap { + track.copyPersonalFrom(it!!) + api.findLibManga(track) + .map { remoteTrack -> + if (remoteTrack != null) { + track.total_chapters = remoteTrack.total_chapters + track.status = remoteTrack.status + } + track + } + } + } + + companion object { + const val READING = 3 + const val COMPLETED = 2 + const val ON_HOLD = 4 + const val DROPPED = 5 + const val PLANNING = 1 + + const val DEFAULT_STATUS = READING + const val DEFAULT_SCORE = 0 + } + + override val name = "Bangumi" + + private val gson: Gson by injectLazy() + + private val interceptor by lazy { BangumiInterceptor(this, gson) } + + private val api by lazy { BangumiApi(client, interceptor) } + + override fun getLogo() = R.drawable.bangumi + + override fun getLogoColor() = Color.rgb(0xF0, 0x91, 0x99) + + override fun getStatusList(): List { + return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLANNING) + } + + 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) + PLANNING -> getString(R.string.plan_to_read) + else -> "" + } + } + + override fun login(username: String, password: String) = login(password) + + fun login(code: String): Completable { + return api.accessToken(code).map { oauth: OAuth? -> + interceptor.newAuth(oauth) + if (oauth != null) { + saveCredentials(oauth.user_id.toString(), oauth.access_token) + } + }.doOnError { + logout() + }.toCompletable() + } + + fun saveToken(oauth: OAuth?) { + val json = gson.toJson(oauth) + preferences.trackToken(this).set(json) + } + + fun restoreToken(): OAuth? { + return try { + gson.fromJson(preferences.trackToken(this).get(), OAuth::class.java) + } catch (e: Exception) { + null + } + } + + override fun logout() { + super.logout() + preferences.trackToken(this).set(null) + interceptor.newAuth(null) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/BangumiApi.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/BangumiApi.kt new file mode 100644 index 0000000000..661c265236 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/BangumiApi.kt @@ -0,0 +1,208 @@ +package eu.kanade.tachiyomi.data.track.bangumi + +import android.net.Uri +import com.github.salomonbrys.kotson.array +import com.github.salomonbrys.kotson.obj +import com.google.gson.Gson +import com.google.gson.JsonObject +import com.google.gson.JsonParser +import eu.kanade.tachiyomi.data.database.models.Track +import eu.kanade.tachiyomi.data.track.TrackManager +import eu.kanade.tachiyomi.data.track.model.TrackSearch +import eu.kanade.tachiyomi.network.POST +import eu.kanade.tachiyomi.network.asObservableSuccess +import okhttp3.CacheControl +import okhttp3.FormBody +import okhttp3.OkHttpClient +import okhttp3.Request +import rx.Observable +import uy.kohesive.injekt.injectLazy +import java.net.URLEncoder + +class BangumiApi(private val client: OkHttpClient, interceptor: BangumiInterceptor) { + + private val gson: Gson by injectLazy() + private val parser = JsonParser() + private val authClient = client.newBuilder().addInterceptor(interceptor).build() + + fun addLibManga(track: Track): Observable { + val body = FormBody.Builder() + .add("rating", track.score.toInt().toString()) + .add("status", track.toBangumiStatus()) + .build() + val request = Request.Builder() + .url("$apiUrl/collection/${track.media_id}/update") + .post(body) + .build() + return authClient.newCall(request) + .asObservableSuccess() + .map { + track + } + } + + fun updateLibManga(track: Track): Observable { + // chapter update + val body = FormBody.Builder() + .add("watched_eps", track.last_chapter_read.toString()) + .build() + val request = Request.Builder() + .url("$apiUrl/subject/${track.media_id}/update/watched_eps") + .post(body) + .build() + + // read status update + val sbody = FormBody.Builder() + .add("status", track.toBangumiStatus()) + .build() + val srequest = Request.Builder() + .url("$apiUrl/collection/${track.media_id}/update") + .post(sbody) + .build() + return authClient.newCall(request) + .asObservableSuccess() + .map { + track + }.flatMap { + authClient.newCall(srequest) + .asObservableSuccess() + .map { + track + } + } + } + + fun search(search: String): Observable> { + val url = Uri.parse( + "$apiUrl/search/subject/${URLEncoder.encode(search, Charsets.UTF_8.name())}").buildUpon() + .appendQueryParameter("max_results", "20") + .build() + val request = Request.Builder() + .url(url.toString()) + .get() + .build() + return authClient.newCall(request) + .asObservableSuccess() + .map { netResponse -> + val responseBody = netResponse.body()?.string().orEmpty() + if (responseBody.isEmpty()) { + throw Exception("Null Response") + } + val response = parser.parse(responseBody).obj["list"]?.array + response?.filter { it.obj["type"].asInt == 1 }?.map { jsonToSearch(it.obj) } + } + + } + + private fun jsonToSearch(obj: JsonObject): TrackSearch { + return TrackSearch.create(TrackManager.BANGUMI).apply { + media_id = obj["id"].asInt + title = obj["name_cn"].asString + cover_url = obj["images"].obj["common"].asString + summary = obj["name"].asString + tracking_url = obj["url"].asString + } + } + + private fun jsonToTrack(mangas: JsonObject): Track { + return Track.create(TrackManager.BANGUMI).apply { + title = mangas["name"].asString + media_id = mangas["id"].asInt + score = if (mangas["rating"] != null) + (if (mangas["rating"].isJsonObject) mangas["rating"].obj["score"].asFloat else 0f) + else 0f + status = Bangumi.DEFAULT_STATUS + tracking_url = mangas["url"].asString + } + } + + fun findLibManga(track: Track): Observable { + val urlMangas = "$apiUrl/subject/${track.media_id}" + val requestMangas = Request.Builder() + .url(urlMangas) + .get() + .build() + + return authClient.newCall(requestMangas) + .asObservableSuccess() + .map { netResponse -> + // get comic info + val responseBody = netResponse.body()?.string().orEmpty() + jsonToTrack(parser.parse(responseBody).obj) + } + } + + fun statusLibManga(track: Track): Observable { + val urlUserRead = "$apiUrl/collection/${track.media_id}" + val requestUserRead = Request.Builder() + .url(urlUserRead) + .cacheControl(CacheControl.FORCE_NETWORK) + .get() + .build() + + // todo get user readed chapter here + return authClient.newCall(requestUserRead) + .asObservableSuccess() + .map { netResponse -> + val resp = netResponse.body()?.string() + val coll = gson.fromJson(resp, Collection::class.java) + track.status = coll.status?.id!! + track.last_chapter_read = coll.ep_status!! + track + } + } + + fun accessToken(code: String): Observable { + return client.newCall(accessTokenRequest(code)).asObservableSuccess().map { netResponse -> + val responseBody = netResponse.body()?.string().orEmpty() + if (responseBody.isEmpty()) { + throw Exception("Null Response") + } + gson.fromJson(responseBody, OAuth::class.java) + } + } + + private fun accessTokenRequest(code: String) = POST(oauthUrl, + body = FormBody.Builder() + .add("grant_type", "authorization_code") + .add("client_id", clientId) + .add("client_secret", clientSecret) + .add("code", code) + .add("redirect_uri", redirectUrl) + .build() + ) + + companion object { + private const val clientId = "bgm10555cda0762e80ca" + private const val clientSecret = "8fff394a8627b4c388cbf349ec865775" + + private const val baseUrl = "https://bangumi.org" + private const val apiUrl = "https://api.bgm.tv" + private const val oauthUrl = "https://bgm.tv/oauth/access_token" + private const val loginUrl = "https://bgm.tv/oauth/authorize" + + private const val redirectUrl = "tachiyomi://bangumi-auth" + private const val baseMangaUrl = "$apiUrl/mangas" + + fun mangaUrl(remoteId: Int): String { + return "$baseMangaUrl/$remoteId" + } + + fun authUrl() = + Uri.parse(loginUrl).buildUpon() + .appendQueryParameter("client_id", clientId) + .appendQueryParameter("response_type", "code") + .appendQueryParameter("redirect_uri", redirectUrl) + .build() + + fun refreshTokenRequest(token: String) = POST(oauthUrl, + body = FormBody.Builder() + .add("grant_type", "refresh_token") + .add("client_id", clientId) + .add("client_secret", clientSecret) + .add("refresh_token", token) + .add("redirect_uri", redirectUrl) + .build()) + } + +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/BangumiInterceptor.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/BangumiInterceptor.kt new file mode 100644 index 0000000000..69565f447b --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/BangumiInterceptor.kt @@ -0,0 +1,61 @@ +package eu.kanade.tachiyomi.data.track.bangumi + +import com.google.gson.Gson +import okhttp3.FormBody +import okhttp3.Interceptor +import okhttp3.Response + +class BangumiInterceptor(val bangumi: Bangumi, val gson: Gson) : Interceptor { + + /** + * OAuth object used for authenticated requests. + */ + private var oauth: OAuth? = bangumi.restoreToken() + + fun addTocken(tocken: String, oidFormBody: FormBody): FormBody { + val newFormBody = FormBody.Builder() + for (i in 0 until oidFormBody.size()) { + newFormBody.add(oidFormBody.name(i), oidFormBody.value(i)) + } + newFormBody.add("access_token", tocken) + return newFormBody.build() + } + + override fun intercept(chain: Interceptor.Chain): Response { + val originalRequest = chain.request() + + val currAuth = oauth ?: throw Exception("Not authenticated with Bangumi") + + if (currAuth.isExpired()) { + val response = chain.proceed(BangumiApi.refreshTokenRequest(currAuth.refresh_token!!)) + if (response.isSuccessful) { + newAuth(gson.fromJson(response.body()!!.string(), OAuth::class.java)) + } else { + response.close() + } + } + + var authRequest = if (originalRequest.method() == "GET") originalRequest.newBuilder() + .header("User-Agent", "Tachiyomi") + .url(originalRequest.url().newBuilder() + .addQueryParameter("access_token", currAuth.access_token).build()) + .build() else originalRequest.newBuilder() + .post(addTocken(currAuth.access_token, originalRequest.body() as FormBody)) + .header("User-Agent", "Tachiyomi") + .build() + + return chain.proceed(authRequest) + } + + fun newAuth(oauth: OAuth?) { + this.oauth = if (oauth == null) null else OAuth( + oauth.access_token, + oauth.token_type, + System.currentTimeMillis() / 1000, + oauth.expires_in, + oauth.refresh_token, + this.oauth?.user_id) + + bangumi.saveToken(oauth) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/BangumiModels.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/BangumiModels.kt new file mode 100644 index 0000000000..83b9ce3054 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/BangumiModels.kt @@ -0,0 +1,22 @@ +package eu.kanade.tachiyomi.data.track.bangumi + +import eu.kanade.tachiyomi.data.database.models.Track + +fun Track.toBangumiStatus() = when (status) { + Bangumi.READING -> "do" + Bangumi.COMPLETED -> "collect" + Bangumi.ON_HOLD -> "on_hold" + Bangumi.DROPPED -> "dropped" + Bangumi.PLANNING -> "wish" + else -> throw NotImplementedError("Unknown status") +} + +fun toTrackStatus(status: String) = when (status) { + "do" -> Bangumi.READING + "collect" -> Bangumi.COMPLETED + "on_hold" -> Bangumi.ON_HOLD + "dropped" -> Bangumi.DROPPED + "wish" -> Bangumi.PLANNING + + else -> throw Exception("Unknown status") +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/Collection.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/Collection.kt new file mode 100644 index 0000000000..732676bf31 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/Collection.kt @@ -0,0 +1,13 @@ +package eu.kanade.tachiyomi.data.track.bangumi + +data class Collection( + val `private`: Int? = 0, + val comment: String? = "", + val ep_status: Int? = 0, + val lasttouch: Int? = 0, + val rating: Int? = 0, + val status: Status? = Status(), + val tag: List? = listOf(), + val user: User? = User(), + val vol_status: Int? = 0 +) \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/OAuth.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/OAuth.kt new file mode 100644 index 0000000000..68dc7e5c4f --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/OAuth.kt @@ -0,0 +1,16 @@ +package eu.kanade.tachiyomi.data.track.bangumi + +data class OAuth( + val access_token: String, + val token_type: String, + val created_at: Long, + val expires_in: Long, + val refresh_token: String?, + val user_id: Long? +) { + + // Access token refersh before expired + fun isExpired() = (System.currentTimeMillis() / 1000) > (created_at + expires_in - 3600) + +} + diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/Status.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/Status.kt new file mode 100644 index 0000000000..78e22e882c --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/Status.kt @@ -0,0 +1,7 @@ +package eu.kanade.tachiyomi.data.track.bangumi + +data class Status( + val id: Int? = 0, + val name: String? = "", + val type: String? = "" +) \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/User.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/User.kt new file mode 100644 index 0000000000..808e4860a6 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/User.kt @@ -0,0 +1,11 @@ +package eu.kanade.tachiyomi.data.track.bangumi + +data class User( + val avatar: Avatar? = Avatar(), + val id: Int? = 0, + val nickname: String? = "", + val sign: String? = "", + val url: String? = "", + val usergroup: Int? = 0, + val username: String? = "" +) \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/BangumiLoginActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/BangumiLoginActivity.kt new file mode 100644 index 0000000000..5654b4efa4 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/BangumiLoginActivity.kt @@ -0,0 +1,50 @@ +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.track.TrackManager +import eu.kanade.tachiyomi.ui.main.MainActivity +import rx.android.schedulers.AndroidSchedulers +import rx.schedulers.Schedulers +import uy.kohesive.injekt.injectLazy + +class BangumiLoginActivity : AppCompatActivity() { + + private val trackManager: TrackManager 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) { + trackManager.bangumi.login(code) + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()) + .subscribe({ + returnToSettings() + }, { + returnToSettings() + }) + } else { + trackManager.bangumi.logout() + returnToSettings() + } + } + + private fun returnToSettings() { + finish() + + val intent = Intent(this, MainActivity::class.java) + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_SINGLE_TOP) + startActivity(intent) + } + +} \ No newline at end of file diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsTrackingController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsTrackingController.kt index 2a8f1f73ac..55bc47059e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsTrackingController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsTrackingController.kt @@ -9,6 +9,7 @@ import eu.kanade.tachiyomi.data.track.TrackManager import eu.kanade.tachiyomi.data.track.TrackService import eu.kanade.tachiyomi.data.track.anilist.AnilistApi import eu.kanade.tachiyomi.data.track.shikimori.ShikimoriApi +import eu.kanade.tachiyomi.data.track.bangumi.BangumiApi import eu.kanade.tachiyomi.util.getResourceColor import eu.kanade.tachiyomi.widget.preference.LoginPreference import eu.kanade.tachiyomi.widget.preference.TrackLoginDialog @@ -63,6 +64,15 @@ class SettingsTrackingController : SettingsController(), tabsIntent.launchUrl(activity, ShikimoriApi.authUrl()) } } + trackPreference(trackManager.bangumi) { + onClick { + val tabsIntent = CustomTabsIntent.Builder() + .setToolbarColor(context.getResourceColor(R.attr.colorPrimary)) + .build() + tabsIntent.intent.addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY) + tabsIntent.launchUrl(activity, BangumiApi.authUrl()) + } + } } } diff --git a/app/src/main/res/drawable-xxxhdpi/bangumi.png b/app/src/main/res/drawable-xxxhdpi/bangumi.png new file mode 100644 index 0000000000000000000000000000000000000000..412a28ba1c2a3ab9cb48d057d9bc9dfcedafc0ee GIT binary patch literal 6388 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 UxXem0tX~FbsOqX