Feature/shikomori track (#1905)

* Add shikomori track

* Fix char 'M'

* Fix date in search
This commit is contained in:
Pavka 2019-04-03 11:14:37 +03:00 committed by inorichi
parent bf60aae9d8
commit a62a7d5330
10 changed files with 488 additions and 1 deletions

View File

@ -52,6 +52,21 @@
android:scheme="tachiyomi" /> android:scheme="tachiyomi" />
</intent-filter> </intent-filter>
</activity> </activity>
<activity
android:name=".ui.setting.ShikomoriLoginActivity"
android:label="Shikomori">
<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="shikimori-auth"
android:scheme="tachiyomi" />
</intent-filter>
</activity>
<activity <activity
android:name=".extension.util.ExtensionInstallActivity" android:name=".extension.util.ExtensionInstallActivity"
android:theme="@android:style/Theme.Translucent.NoTitleBar"/> android:theme="@android:style/Theme.Translucent.NoTitleBar"/>

View File

@ -4,6 +4,7 @@ import android.content.Context
import eu.kanade.tachiyomi.data.track.anilist.Anilist import eu.kanade.tachiyomi.data.track.anilist.Anilist
import eu.kanade.tachiyomi.data.track.kitsu.Kitsu import eu.kanade.tachiyomi.data.track.kitsu.Kitsu
import eu.kanade.tachiyomi.data.track.myanimelist.Myanimelist import eu.kanade.tachiyomi.data.track.myanimelist.Myanimelist
import eu.kanade.tachiyomi.data.track.shikomori.Shikomori
class TrackManager(private val context: Context) { class TrackManager(private val context: Context) {
@ -11,6 +12,7 @@ class TrackManager(private val context: Context) {
const val MYANIMELIST = 1 const val MYANIMELIST = 1
const val ANILIST = 2 const val ANILIST = 2
const val KITSU = 3 const val KITSU = 3
const val SHIKOMORI = 4
} }
val myAnimeList = Myanimelist(context, MYANIMELIST) val myAnimeList = Myanimelist(context, MYANIMELIST)
@ -19,7 +21,9 @@ class TrackManager(private val context: Context) {
val kitsu = Kitsu(context, KITSU) val kitsu = Kitsu(context, KITSU)
val services = listOf(myAnimeList, aniList, kitsu) val shikomori = Shikomori(context, SHIKOMORI)
val services = listOf(myAnimeList, aniList, kitsu, shikomori)
fun getService(id: Int) = services.find { it.id == id } fun getService(id: Int) = services.find { it.id == id }

View File

@ -0,0 +1,13 @@
package eu.kanade.tachiyomi.data.track.shikomori
data class OAuth(
val access_token: String,
val token_type: String,
val created_at: Long,
val expires_in: Long,
val refresh_token: String?) {
// Access token lives 1 day
fun isExpired() = (System.currentTimeMillis() / 1000) > (created_at + expires_in - 3600)
}

View File

@ -0,0 +1,138 @@
package eu.kanade.tachiyomi.data.track.shikomori
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 Shikomori(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, getUsername())
}
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, getUsername())
}
override fun bind(track: Track): Observable<Track> {
return api.findLibManga(track, getUsername())
.flatMap { remoteTrack ->
if (remoteTrack != null) {
track.copyPersonalFrom(remoteTrack)
track.library_id = remoteTrack.library_id
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)
}
}
}
override fun search(query: String): Observable<List<TrackSearch>> {
return api.search(query)
}
override fun refresh(track: Track): Observable<Track> {
return api.findLibManga(track, getUsername())
.map { remoteTrack ->
if (remoteTrack != null) {
track.copyPersonalFrom(remoteTrack)
track.total_chapters = remoteTrack.total_chapters
}
track
}
}
companion object {
const val READING = 1
const val COMPLETED = 2
const val ON_HOLD = 3
const val DROPPED = 4
const val PLANNING = 5
const val REPEATING = 6
const val DEFAULT_STATUS = READING
const val DEFAULT_SCORE = 0
}
override val name = "Shikomori"
private val gson: Gson by injectLazy()
private val interceptor by lazy { ShikomoriInterceptor(this, gson) }
private val api by lazy { ShikomoriApi(client, interceptor) }
override fun getLogo() = R.drawable.shikomori
override fun getLogoColor() = Color.rgb(40, 40, 40)
override fun getStatusList(): List<Int> {
return listOf(READING, COMPLETED, ON_HOLD, DROPPED, PLANNING, REPEATING)
}
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)
REPEATING -> getString(R.string.repeating)
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) {
val user = api.getCurrentUser()
saveCredentials(user.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)
}
}

View File

@ -0,0 +1,189 @@
package eu.kanade.tachiyomi.data.track.shikomori
import android.net.Uri
import com.github.salomonbrys.kotson.array
import com.github.salomonbrys.kotson.jsonObject
import com.github.salomonbrys.kotson.nullString
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.GET
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.asObservableSuccess
import okhttp3.*
import rx.Observable
import uy.kohesive.injekt.injectLazy
class ShikomoriApi(private val client: OkHttpClient, interceptor: ShikomoriInterceptor) {
private val gson: Gson by injectLazy()
private val parser = JsonParser()
private val jsonime = MediaType.parse("application/json; charset=utf-8")
private val authClient = client.newBuilder().addInterceptor(interceptor).build()
fun addLibManga(track: Track, user_id: String): Observable<Track> {
val payload = jsonObject(
"user_rate" to jsonObject(
"user_id" to user_id,
"target_id" to track.media_id,
"target_type" to "Manga",
"chapters" to track.last_chapter_read,
"score" to track.score.toInt(),
"status" to track.toShikomoriStatus()
)
)
val body = RequestBody.create(jsonime, payload.toString())
val request = Request.Builder()
.url("$apiUrl/v2/user_rates")
.post(body)
.build()
return authClient.newCall(request)
.asObservableSuccess()
.map {
track
}
}
fun updateLibManga(track: Track, user_id: String): Observable<Track> = addLibManga(track, user_id)
fun search(search: String): Observable<List<TrackSearch>> {
val url = Uri.parse("$apiUrl/mangas").buildUpon()
.appendQueryParameter("order", "popularity")
.appendQueryParameter("search", search)
.appendQueryParameter("limit", "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).array
response.map { jsonToSearch(it.obj) }
}
}
private fun jsonToSearch(obj: JsonObject): TrackSearch {
return TrackSearch.create(TrackManager.SHIKOMORI).apply {
media_id = obj["id"].asInt
title = obj["name"].asString
total_chapters = obj["chapters"].asInt
cover_url = baseUrl + obj["image"].obj["preview"].asString
summary = ""
tracking_url = baseUrl + obj["url"].asString
publishing_status = obj["status"].asString
publishing_type = obj["kind"].asString
start_date = obj.get("aired_on").nullString.orEmpty()
}
}
private fun jsonToTrack(obj: JsonObject): Track {
return Track.create(TrackManager.SHIKOMORI).apply {
media_id = obj["id"].asInt
title = ""
last_chapter_read = obj["chapters"].asInt
total_chapters = obj["chapters"].asInt
score = (obj["score"].asInt).toFloat()
status = toTrackStatus(obj["status"].asString)
}
}
fun findLibManga(track: Track, user_id: String): Observable<Track?> {
val url = Uri.parse("$apiUrl/v2/user_rates").buildUpon()
.appendQueryParameter("user_id", user_id)
.appendQueryParameter("target_id", track.media_id.toString())
.appendQueryParameter("target_type", "Manga")
.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).array
if (response.size() > 1) {
throw Exception("Too much mangas in response")
}
val entry = response.map {
jsonToTrack(it.obj)
}
entry.firstOrNull()
}
}
fun getCurrentUser(): Int {
val user = authClient.newCall(GET("$apiUrl/users/whoami")).execute().body()?.string()
return parser.parse(user).obj["id"].asInt
}
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 = "1aaf4cf232372708e98b5abc813d795b539c5a916dbbfe9ac61bf02a360832cc"
private const val clientSecret = "229942c742dd4cde803125d17d64501d91c0b12e14cb1e5120184d77d67024c0"
private const val baseUrl = "https://shikimori.org"
private const val apiUrl = "https://shikimori.org/api"
private const val oauthUrl = "https://shikimori.org/oauth/token"
private const val loginUrl = "https://shikimori.org/oauth/authorize"
private const val redirectUrl = "tachiyomi://shikimori-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("redirect_uri", redirectUrl)
.appendQueryParameter("response_type", "code")
.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)
.build())
}
}

View File

@ -0,0 +1,43 @@
package eu.kanade.tachiyomi.data.track.shikomori
import com.google.gson.Gson
import okhttp3.Interceptor
import okhttp3.Response
class ShikomoriInterceptor(val shikomori: Shikomori, val gson: Gson) : Interceptor {
/**
* OAuth object used for authenticated requests.
*/
private var oauth: OAuth? = shikomori.restoreToken()
override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest = chain.request()
val currAuth = oauth ?: throw Exception("Not authenticated with Shikomori")
val refreshToken = currAuth.refresh_token!!
// Refresh access token if expired.
if (currAuth.isExpired()) {
val response = chain.proceed(ShikomoriApi.refreshTokenRequest(refreshToken))
if (response.isSuccessful) {
newAuth(gson.fromJson(response.body()!!.string(), OAuth::class.java))
} else {
response.close()
}
}
// Add the authorization header to the original request.
val authRequest = originalRequest.newBuilder()
.addHeader("Authorization", "Bearer ${oauth!!.access_token}")
.header("User-Agent", "Tachiyomi")
.build()
return chain.proceed(authRequest)
}
fun newAuth(oauth: OAuth?) {
this.oauth = oauth
shikomori.saveToken(oauth)
}
}

View File

@ -0,0 +1,24 @@
package eu.kanade.tachiyomi.data.track.shikomori
import eu.kanade.tachiyomi.data.database.models.Track
fun Track.toShikomoriStatus() = when (status) {
Shikomori.READING -> "watching"
Shikomori.COMPLETED -> "completed"
Shikomori.ON_HOLD -> "on_hold"
Shikomori.DROPPED -> "dropped"
Shikomori.PLANNING -> "planned"
Shikomori.REPEATING -> "rewatching"
else -> throw NotImplementedError("Unknown status")
}
fun toTrackStatus(status: String) = when (status) {
"watching" -> Shikomori.READING
"completed" -> Shikomori.COMPLETED
"on_hold" -> Shikomori.ON_HOLD
"dropped" -> Shikomori.DROPPED
"planned" -> Shikomori.PLANNING
"rewatching" -> Shikomori.REPEATING
else -> throw Exception("Unknown status")
}

View File

@ -8,6 +8,7 @@ import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.track.TrackManager import eu.kanade.tachiyomi.data.track.TrackManager
import eu.kanade.tachiyomi.data.track.TrackService import eu.kanade.tachiyomi.data.track.TrackService
import eu.kanade.tachiyomi.data.track.anilist.AnilistApi import eu.kanade.tachiyomi.data.track.anilist.AnilistApi
import eu.kanade.tachiyomi.data.track.shikomori.ShikomoriApi
import eu.kanade.tachiyomi.util.getResourceColor import eu.kanade.tachiyomi.util.getResourceColor
import eu.kanade.tachiyomi.widget.preference.LoginPreference import eu.kanade.tachiyomi.widget.preference.LoginPreference
import eu.kanade.tachiyomi.widget.preference.TrackLoginDialog import eu.kanade.tachiyomi.widget.preference.TrackLoginDialog
@ -53,6 +54,15 @@ class SettingsTrackingController : SettingsController(),
dialog.showDialog(router) dialog.showDialog(router)
} }
} }
trackPreference(trackManager.shikomori) {
onClick {
val tabsIntent = CustomTabsIntent.Builder()
.setToolbarColor(context.getResourceColor(R.attr.colorPrimary))
.build()
tabsIntent.intent.addFlags(Intent.FLAG_ACTIVITY_NO_HISTORY)
tabsIntent.launchUrl(activity, ShikomoriApi.authUrl())
}
}
} }
} }
@ -70,6 +80,7 @@ class SettingsTrackingController : SettingsController(),
super.onActivityResumed(activity) super.onActivityResumed(activity)
// Manually refresh anilist holder // Manually refresh anilist holder
updatePreference(trackManager.aniList.id) updatePreference(trackManager.aniList.id)
updatePreference(trackManager.shikomori.id)
} }
private fun updatePreference(id: Int) { private fun updatePreference(id: Int) {

View File

@ -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 ShikomoriLoginActivity : 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.shikomori.login(code)
.subscribeOn(Schedulers.io())
.observeOn(AndroidSchedulers.mainThread())
.subscribe({
returnToSettings()
}, {
returnToSettings()
})
} else {
trackManager.shikomori.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)
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 8.6 KiB