mirror of
				https://github.com/mihonapp/mihon.git
				synced 2025-10-30 22:07:57 +01:00 
			
		
		
		
	Feature/shikomori track (#1905)
* Add shikomori track * Fix char 'M' * Fix date in search
This commit is contained in:
		| @@ -4,6 +4,7 @@ import android.content.Context | ||||
| 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.shikomori.Shikomori | ||||
|  | ||||
| class TrackManager(private val context: Context) { | ||||
|  | ||||
| @@ -11,6 +12,7 @@ class TrackManager(private val context: Context) { | ||||
|         const val MYANIMELIST = 1 | ||||
|         const val ANILIST = 2 | ||||
|         const val KITSU = 3 | ||||
|         const val SHIKOMORI = 4 | ||||
|     } | ||||
|  | ||||
|     val myAnimeList = Myanimelist(context, MYANIMELIST) | ||||
| @@ -19,7 +21,9 @@ class TrackManager(private val context: Context) { | ||||
|  | ||||
|     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 } | ||||
|  | ||||
|   | ||||
| @@ -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) | ||||
| } | ||||
|  | ||||
| @@ -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) | ||||
|     } | ||||
| } | ||||
| @@ -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()) | ||||
|  | ||||
|     } | ||||
|  | ||||
| } | ||||
| @@ -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) | ||||
|     } | ||||
| } | ||||
| @@ -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") | ||||
| } | ||||
| @@ -8,6 +8,7 @@ import eu.kanade.tachiyomi.R | ||||
| 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.shikomori.ShikomoriApi | ||||
| import eu.kanade.tachiyomi.util.getResourceColor | ||||
| import eu.kanade.tachiyomi.widget.preference.LoginPreference | ||||
| import eu.kanade.tachiyomi.widget.preference.TrackLoginDialog | ||||
| @@ -53,6 +54,15 @@ class SettingsTrackingController : SettingsController(), | ||||
|                     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) | ||||
|         // Manually refresh anilist holder | ||||
|         updatePreference(trackManager.aniList.id) | ||||
|         updatePreference(trackManager.shikomori.id) | ||||
|     } | ||||
|  | ||||
|     private fun updatePreference(id: Int) { | ||||
|   | ||||
| @@ -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) | ||||
|     } | ||||
|  | ||||
| } | ||||
		Reference in New Issue
	
	Block a user