From 01bfaa99ade3c0ed12b0894bfb0bb6c0c9bdbb98 Mon Sep 17 00:00:00 2001 From: MajorTanya <39014446+MajorTanya@users.noreply.github.com> Date: Thu, 20 Aug 2026 20:11:27 +0200 Subject: [PATCH] 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. --- CHANGELOG.md | 1 + .../domain/track/service/TrackPreferences.kt | 3 + .../tachiyomi/data/track/kitsu/Kitsu.kt | 68 +++++++++++++++++-- .../tachiyomi/data/track/kitsu/KitsuApi.kt | 2 +- .../tachiyomi/data/track/kitsu/KitsuUtils.kt | 4 -- .../data/track/kitsu/dto/KitsuListSearch.kt | 2 +- .../data/track/kitsu/dto/KitsuUser.kt | 1 + .../sqldelight/tachiyomi/migrations/14.sqm | 5 ++ 8 files changed, 74 insertions(+), 12 deletions(-) create mode 100644 data/src/main/sqldelight/tachiyomi/migrations/14.sqm diff --git a/CHANGELOG.md b/CHANGELOG.md index f253028b7..b3f891a79 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -13,6 +13,7 @@ The format is a modified version of [Keep a Changelog](https://keepachangelog.co ## [Unreleased] ### 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 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 - Show updates and upcoming filter icon as active for categories ([@Secozzi](https://github.com/Secozzi)) ([#3772](https://github.com/mihonapp/mihon/pull/3772)) diff --git a/app/src/main/java/eu/kanade/domain/track/service/TrackPreferences.kt b/app/src/main/java/eu/kanade/domain/track/service/TrackPreferences.kt index 03dd4cdbc..6af7d2d08 100644 --- a/app/src/main/java/eu/kanade/domain/track/service/TrackPreferences.kt +++ b/app/src/main/java/eu/kanade/domain/track/service/TrackPreferences.kt @@ -6,6 +6,7 @@ import dev.zacsweers.metro.SingleIn import eu.kanade.domain.track.model.AutoTrackState import eu.kanade.tachiyomi.data.track.Tracker 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 tachiyomi.core.common.preference.Preference import tachiyomi.core.common.preference.PreferenceStore @@ -47,6 +48,8 @@ class TrackPreferences( val anilistScoreType: Preference = preferenceStore.getString("anilist_score_type", Anilist.POINT_10) + val kitsuScoreType: Preference = preferenceStore.getString("kitsu_score_type", Kitsu.RATING_ADVANCED) + val mangabakaScoreType: Preference = preferenceStore.getString("mangabaka_score_type", MangaBaka.STEP_1) val autoUpdateTrack: Preference = preferenceStore.getBoolean("pref_auto_update_manga_sync_key", true) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/Kitsu.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/Kitsu.kt index edd21e15f..8a5a35f0a 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/Kitsu.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/Kitsu.kt @@ -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.model.TrackSearch import kotlinx.serialization.json.Json +import logcat.LogPriority +import tachiyomi.core.common.util.system.logcat import tachiyomi.i18n.MR import uy.kohesive.injekt.injectLazy import java.text.DecimalFormat @@ -15,6 +17,12 @@ import tachiyomi.domain.track.model.Track as DomainTrack class Kitsu(id: Long) : BaseTracker(id, "Kitsu"), DeletableTracker { + private data class RatingSystem( + val name: String, + val scoreList: List, + val twentyScale: List, + ) + companion object { const val READING = 1L const val COMPLETED = 2L @@ -22,6 +30,33 @@ class Kitsu(id: Long) : BaseTracker(id, "Kitsu"), DeletableTracker { const val DROPPED = 4L 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:" } @@ -35,6 +70,8 @@ class Kitsu(id: Long) : BaseTracker(id, "Kitsu"), DeletableTracker { private val api by lazy { KitsuApi(client, interceptor) } + private val scorePreference by lazy { trackPreferences.kitsuScoreType } + override fun getLogo() = R.drawable.brand_kitsu override fun getStatusList(): List { @@ -56,18 +93,29 @@ class Kitsu(id: Long) : BaseTracker(id, "Kitsu"), DeletableTracker { override fun getCompletionStatus(): Long = COMPLETED - override fun getScoreList(): List { - val df = DecimalFormat("0.#") - return (listOf("0") + IntRange(2, 20).map { df.format(it / 2f) }) + private fun getCurrentRatingSystem(): RatingSystem { + val ratingSystem = scorePreference.get() + return ratingSystems[ratingSystem] ?: throw Exception("Unknown score type $ratingSystem") + } + + override fun getScoreList(): List = 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 { - 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 { - val df = DecimalFormat("0.#") - return df.format(track.score) + val ratingSystem = getCurrentRatingSystem() + // 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 { @@ -136,6 +184,14 @@ class Kitsu(id: Long) : BaseTracker(id, "Kitsu"), DeletableTracker { val token = api.login(username, password) interceptor.newAuth(token) 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) saveCredentials(username, currentUser.id) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuApi.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuApi.kt index 2c5b28de7..b8b1b32dd 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuApi.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuApi.kt @@ -96,7 +96,7 @@ class KitsuApi(private val client: OkHttpClient, interceptor: KitsuInterceptor) putJsonObject("attributes") { put("status", track.toApiStatus()) 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("finishedAt", KitsuDateHelper.convert(track.finished_reading_date)) put("private", track.private) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuUtils.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuUtils.kt index 02a88e09c..8cea94bc3 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuUtils.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/KitsuUtils.kt @@ -10,7 +10,3 @@ fun Track.toApiStatus() = when (status) { Kitsu.PLAN_TO_READ -> "planned" else -> throw Exception("Unknown status") } - -fun Track.toApiScore(): String? { - return if (score > 0) (score * 2).toInt().toString() else null -} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/dto/KitsuListSearch.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/dto/KitsuListSearch.kt index 0fd42ce0b..d5143530c 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/dto/KitsuListSearch.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/dto/KitsuListSearch.kt @@ -41,7 +41,7 @@ data class KitsuListSearchResult( "planned" -> Kitsu.PLAN_TO_READ 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() private = userDataAttrs.private } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/dto/KitsuUser.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/dto/KitsuUser.kt index 075931372..547db25ba 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/dto/KitsuUser.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/kitsu/dto/KitsuUser.kt @@ -16,4 +16,5 @@ data class KitsuUser( @Serializable data class KitsuUserAttributes( val name: String, + val ratingSystem: String, ) diff --git a/data/src/main/sqldelight/tachiyomi/migrations/14.sqm b/data/src/main/sqldelight/tachiyomi/migrations/14.sqm new file mode 100644 index 000000000..1519cd665 --- /dev/null +++ b/data/src/main/sqldelight/tachiyomi/migrations/14.sqm @@ -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;