Add support for using the user's chosen Kitsu rating scales (#3818)

Add support for different Kitsu rating scales

Includes a database migration because we have been scaling Kitsu's
2-20 `ratingTwenty` integer value to a 1-10 (step 0.5) value and
stored that in the database.

I'm also "rounding" the values to the next lowest valid one in the
current rating system to mirror Kitsu's behaviour for this. Both Kitsu
and the app keep the misfit value around until the score is otherwise
edited, at which point the interface constrains the user selection to
only valid values (for the current system).

Maybe the RatingSystem data class is overkill, but a previous version
of mine had three constants for each system that had to be manually
associated at each corner. Encapsulating those in a little map of data
classes seemed the more ergonomic solution.
This commit is contained in:
MajorTanya
2026-08-20 20:11:27 +02:00
committed by GitHub
parent 4b5a0539e5
commit 01bfaa99ad
8 changed files with 74 additions and 12 deletions
+1
View File
@@ -13,6 +13,7 @@ The format is a modified version of [Keep a Changelog](https://keepachangelog.co
## [Unreleased] ## [Unreleased]
### Added ### Added
- Add `id:` prefix search to remaining trackers (AniList, Bangumi, Kitsu, MangaUpdates, Shikimori, and Hikka) ([@MajorTanya](https://github.com/MajorTanya)) ([#3776](https://github.com/mihonapp/mihon/pull/3776)) - Add `id:` prefix search to remaining trackers (AniList, Bangumi, Kitsu, MangaUpdates, Shikimori, and Hikka) ([@MajorTanya](https://github.com/MajorTanya)) ([#3776](https://github.com/mihonapp/mihon/pull/3776))
- Add support for using the user's chosen rating system for Kitsu ([@MajorTanya](https://github.com/MajorTanya)) ([#3818](https://github.com/mihonapp/mihon/pull/3818))
### Improved ### Improved
- Show updates and upcoming filter icon as active for categories ([@Secozzi](https://github.com/Secozzi)) ([#3772](https://github.com/mihonapp/mihon/pull/3772)) - Show updates and upcoming filter icon as active for categories ([@Secozzi](https://github.com/Secozzi)) ([#3772](https://github.com/mihonapp/mihon/pull/3772))
@@ -6,6 +6,7 @@ import dev.zacsweers.metro.SingleIn
import eu.kanade.domain.track.model.AutoTrackState import eu.kanade.domain.track.model.AutoTrackState
import eu.kanade.tachiyomi.data.track.Tracker import eu.kanade.tachiyomi.data.track.Tracker
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.mangabaka.MangaBaka import eu.kanade.tachiyomi.data.track.mangabaka.MangaBaka
import tachiyomi.core.common.preference.Preference import tachiyomi.core.common.preference.Preference
import tachiyomi.core.common.preference.PreferenceStore import tachiyomi.core.common.preference.PreferenceStore
@@ -47,6 +48,8 @@ class TrackPreferences(
val anilistScoreType: Preference<String> = preferenceStore.getString("anilist_score_type", Anilist.POINT_10) val anilistScoreType: Preference<String> = preferenceStore.getString("anilist_score_type", Anilist.POINT_10)
val kitsuScoreType: Preference<String> = preferenceStore.getString("kitsu_score_type", Kitsu.RATING_ADVANCED)
val mangabakaScoreType: Preference<String> = preferenceStore.getString("mangabaka_score_type", MangaBaka.STEP_1) val mangabakaScoreType: Preference<String> = preferenceStore.getString("mangabaka_score_type", MangaBaka.STEP_1)
val autoUpdateTrack: Preference<Boolean> = preferenceStore.getBoolean("pref_auto_update_manga_sync_key", true) val autoUpdateTrack: Preference<Boolean> = preferenceStore.getBoolean("pref_auto_update_manga_sync_key", true)
@@ -8,6 +8,8 @@ import eu.kanade.tachiyomi.data.track.DeletableTracker
import eu.kanade.tachiyomi.data.track.kitsu.dto.KitsuOAuth import eu.kanade.tachiyomi.data.track.kitsu.dto.KitsuOAuth
import eu.kanade.tachiyomi.data.track.model.TrackSearch import eu.kanade.tachiyomi.data.track.model.TrackSearch
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import logcat.LogPriority
import tachiyomi.core.common.util.system.logcat
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import uy.kohesive.injekt.injectLazy import uy.kohesive.injekt.injectLazy
import java.text.DecimalFormat import java.text.DecimalFormat
@@ -15,6 +17,12 @@ import tachiyomi.domain.track.model.Track as DomainTrack
class Kitsu(id: Long) : BaseTracker(id, "Kitsu"), DeletableTracker { class Kitsu(id: Long) : BaseTracker(id, "Kitsu"), DeletableTracker {
private data class RatingSystem(
val name: String,
val scoreList: List<String>,
val twentyScale: List<Int>,
)
companion object { companion object {
const val READING = 1L const val READING = 1L
const val COMPLETED = 2L const val COMPLETED = 2L
@@ -22,6 +30,33 @@ class Kitsu(id: Long) : BaseTracker(id, "Kitsu"), DeletableTracker {
const val DROPPED = 4L const val DROPPED = 4L
const val PLAN_TO_READ = 5L const val PLAN_TO_READ = 5L
const val RATING_SIMPLE = "simple"
const val RATING_REGULAR = "regular"
const val RATING_ADVANCED = "advanced"
private val ratingSystems = mapOf(
// Smileys
RATING_SIMPLE to RatingSystem(
name = RATING_SIMPLE,
scoreList = listOf("-", "😡", "😐", "😊", "😀"),
twentyScale = (2..20 step 6).toList(), // 2, 8, 14, 20
),
// DecimalFormatter is not thread safe, so new formatters for each map instead of extracted val attribute
// to not incite reuse
// Stars (0.5-5 step 0.5)
RATING_REGULAR to RatingSystem(
name = RATING_REGULAR,
scoreList = (0..10).map { it / 2.0 }.map(DecimalFormat("0.#")::format).map { "$it" },
twentyScale = (2..20 step 2).toList(), // 2, 4, ..., 18, 20
),
// 10 point decimal (step 0.5, starting at 1) + 0 for our "not rated" placeholder
RATING_ADVANCED to RatingSystem(
name = RATING_ADVANCED,
scoreList = listOf("0") + (2..20).map { it / 2.0 }.map(DecimalFormat("0.#")::format),
twentyScale = (2..20).toList(), // 2, 3, ..., 19, 20
),
)
private const val SEARCH_ID_PREFIX = "id:" private const val SEARCH_ID_PREFIX = "id:"
} }
@@ -35,6 +70,8 @@ class Kitsu(id: Long) : BaseTracker(id, "Kitsu"), DeletableTracker {
private val api by lazy { KitsuApi(client, interceptor) } private val api by lazy { KitsuApi(client, interceptor) }
private val scorePreference by lazy { trackPreferences.kitsuScoreType }
override fun getLogo() = R.drawable.brand_kitsu override fun getLogo() = R.drawable.brand_kitsu
override fun getStatusList(): List<Long> { override fun getStatusList(): List<Long> {
@@ -56,18 +93,29 @@ class Kitsu(id: Long) : BaseTracker(id, "Kitsu"), DeletableTracker {
override fun getCompletionStatus(): Long = COMPLETED override fun getCompletionStatus(): Long = COMPLETED
override fun getScoreList(): List<String> { private fun getCurrentRatingSystem(): RatingSystem {
val df = DecimalFormat("0.#") val ratingSystem = scorePreference.get()
return (listOf("0") + IntRange(2, 20).map { df.format(it / 2f) }) return ratingSystems[ratingSystem] ?: throw Exception("Unknown score type $ratingSystem")
}
override fun getScoreList(): List<String> = getCurrentRatingSystem().scoreList
override fun get10PointScore(track: DomainTrack): Double {
// score is stored in Kitsu's native 2-20 scale
return track.score / 2.0
} }
override fun indexToScore(index: Int): Double { override fun indexToScore(index: Int): Double {
return if (index > 0) (index + 1) / 2.0 else 0.0 if (index == 0) return 0.0
return getCurrentRatingSystem().twentyScale[index - 1].toDouble()
} }
override fun displayScore(track: DomainTrack): String { override fun displayScore(track: DomainTrack): String {
val df = DecimalFormat("0.#") val ratingSystem = getCurrentRatingSystem()
return df.format(track.score) // Since Kitsu's valid score range is 2-20, unset values of -1.0 or 0.0 will both return -1 from indexOfLast
// which is turned into index 0 of the scoreList, giving us the "unset" display score (- or 0).
// Proper scores are "rounded down" to the nearest value of the scale (also what Kitsu's website does)
return ratingSystem.scoreList[ratingSystem.twentyScale.indexOfLast { it <= track.score } + 1]
} }
private suspend fun add(track: Track): Track { private suspend fun add(track: Track): Track {
@@ -136,6 +184,14 @@ class Kitsu(id: Long) : BaseTracker(id, "Kitsu"), DeletableTracker {
val token = api.login(username, password) val token = api.login(username, password)
interceptor.newAuth(token) interceptor.newAuth(token)
val currentUser = api.getCurrentUser() val currentUser = api.getCurrentUser()
val ratingSystem = currentUser.attributes.ratingSystem
if (ratingSystem in listOf(RATING_SIMPLE, RATING_REGULAR, RATING_ADVANCED)) {
scorePreference.set(ratingSystem)
} else {
logcat(LogPriority.ERROR) { "Unsupported Kitsu score type: $ratingSystem" }
scorePreference.set(RATING_ADVANCED)
}
saveDisplayUsername(currentUser.attributes.name) saveDisplayUsername(currentUser.attributes.name)
saveCredentials(username, currentUser.id) saveCredentials(username, currentUser.id)
} }
@@ -96,7 +96,7 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor)
putJsonObject("attributes") { putJsonObject("attributes") {
put("status", track.toApiStatus()) put("status", track.toApiStatus())
put("progress", track.last_chapter_read.toInt()) put("progress", track.last_chapter_read.toInt())
put("ratingTwenty", track.toApiScore()) put("ratingTwenty", track.score.takeIf { it > 0 }?.toInt())
put("startedAt", KitsuDateHelper.convert(track.started_reading_date)) put("startedAt", KitsuDateHelper.convert(track.started_reading_date))
put("finishedAt", KitsuDateHelper.convert(track.finished_reading_date)) put("finishedAt", KitsuDateHelper.convert(track.finished_reading_date))
put("private", track.private) put("private", track.private)
@@ -10,7 +10,3 @@ fun Track.toApiStatus() = when (status) {
Kitsu.PLAN_TO_READ -> "planned" Kitsu.PLAN_TO_READ -> "planned"
else -> throw Exception("Unknown status") else -> throw Exception("Unknown status")
} }
fun Track.toApiScore(): String? {
return if (score > 0) (score * 2).toInt().toString() else null
}
@@ -41,7 +41,7 @@ data class KitsuListSearchResult(
"planned" -> Kitsu.PLAN_TO_READ "planned" -> Kitsu.PLAN_TO_READ
else -> throw Exception("Unknown status") else -> throw Exception("Unknown status")
} }
score = userDataAttrs.ratingTwenty?.let { it / 2.0 } ?: 0.0 score = userDataAttrs.ratingTwenty?.toDouble() ?: 0.0
last_chapter_read = userDataAttrs.progress.toDouble() last_chapter_read = userDataAttrs.progress.toDouble()
private = userDataAttrs.private private = userDataAttrs.private
} }
@@ -16,4 +16,5 @@ data class KitsuUser(
@Serializable @Serializable
data class KitsuUserAttributes( data class KitsuUserAttributes(
val name: String, val name: String,
val ratingSystem: String,
) )
@@ -0,0 +1,5 @@
-- rescale Kitsu (sync_id 3) scores to native 2-20 int scale
-- old scores were rescaled from Kitsu's 2-20 down to 1, 1.5, ..., 10 at the API boundary
UPDATE manga_sync
SET score = score * 2
WHERE sync_id = 3;