Sfoglia il codice sorgente

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
fei long 5 anni fa
parent
commit
3abae1cc75

+ 14 - 0
app/src/main/AndroidManifest.xml

@@ -75,6 +75,20 @@
                     android:scheme="tachiyomi" />
             </intent-filter>
         </activity>
+        <activity
+            android:name=".ui.setting.BangumiLoginActivity"
+            android:label="Bangumi">
+            <intent-filter>
+                <action android:name="android.intent.action.VIEW" />
+
+                <category android:name="android.intent.category.DEFAULT" />
+                <category android:name="android.intent.category.BROWSABLE" />
+
+                <data
+                    android:host="bangumi-auth"
+                    android:scheme="tachiyomi" />
+            </intent-filter>
+        </activity>
 
         <activity
             android:name=".extension.util.ExtensionInstallActivity"

+ 5 - 1
app/src/main/java/eu/kanade/tachiyomi/data/track/TrackManager.kt

@@ -5,6 +5,7 @@ import eu.kanade.tachiyomi.data.track.anilist.Anilist
 import eu.kanade.tachiyomi.data.track.kitsu.Kitsu
 import eu.kanade.tachiyomi.data.track.myanimelist.Myanimelist
 import eu.kanade.tachiyomi.data.track.shikimori.Shikimori
+import eu.kanade.tachiyomi.data.track.bangumi.Bangumi
 
 class TrackManager(private val context: Context) {
 
@@ -13,6 +14,7 @@ class TrackManager(private val context: Context) {
         const val ANILIST = 2
         const val KITSU = 3
         const val SHIKIMORI = 4
+        const val BANGUMI = 5
     }
 
     val myAnimeList = Myanimelist(context, MYANIMELIST)
@@ -23,7 +25,9 @@ class TrackManager(private val context: Context) {
 
     val shikimori = Shikimori(context, SHIKIMORI)
 
-    val services = listOf(myAnimeList, aniList, kitsu, shikimori)
+    val bangumi = Bangumi(context, BANGUMI)
+
+    val services = listOf(myAnimeList, aniList, kitsu, shikimori, bangumi)
 
     fun getService(id: Int) = services.find { it.id == id }
 

+ 7 - 0
app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/Avatar.kt

@@ -0,0 +1,7 @@
+package eu.kanade.tachiyomi.data.track.bangumi
+
+data class Avatar(
+  val large: String? = "",
+  val medium: String? = "",
+  val small: String? = ""
+)

+ 144 - 0
app/src/main/java/eu/kanade/tachiyomi/data/track/bangumi/Bangumi.kt

@@ -0,0 +1,144 @@
+package eu.kanade.tachiyomi.data.track.bangumi
+
+import android.content.Context
+import android.graphics.Color
+import com.google.gson.Gson
+import eu.kanade.tachiyomi.R
+import eu.kanade.tachiyomi.data.database.models.Track
+import eu.kanade.tachiyomi.data.track.TrackService
+import eu.kanade.tachiyomi.data.track.model.TrackSearch
+import rx.Completable
+import rx.Observable
+import uy.kohesive.injekt.injectLazy
+
+class Bangumi(private val context: Context, id: Int) : TrackService(id) {
+
+  override fun getScoreList(): List<String> {
+    return IntRange(0, 10).map(Int::toString)
+  }
+
+  override fun displayScore(track: Track): String {
+    return track.score.toInt().toString()
+  }
+
+  override fun add(track: Track): Observable<Track> {
+    return api.addLibManga(track)
+  }
+
+  override fun update(track: Track): Observable<Track> {
+    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<Track> {
+    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<List<TrackSearch>> {
+    return api.search(query)
+  }
+
+  override fun refresh(track: Track): Observable<Track> {
+    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<Int> {
+    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)
+  }
+}

+ 208 - 0
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<Track> {
+    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<Track> {
+    // 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<List<TrackSearch>> {
+    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<Track?> {
+    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<Track?> {
+    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<OAuth> {
+    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())
+  }
+
+}

+ 61 - 0
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)
+  }
+}

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

+ 13 - 0
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<String?>? = listOf(),
+  val user: User? = User(),
+  val vol_status: Int? = 0
+)

+ 16 - 0
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)
+
+}
+

+ 7 - 0
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? = ""
+)

+ 11 - 0
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? = ""
+)

+ 50 - 0
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)
+    }
+
+}

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

BIN
app/src/main/res/drawable-xxxhdpi/bangumi.png