Add Hikka tracker support (#1386)

Co-authored-by: AntsyLich <59261191+AntsyLich@users.noreply.github.com>
This commit is contained in:
LorgOn
2026-07-07 00:18:06 +03:00
committed by GitHub
parent 3b078331a0
commit aa46039e6c
19 changed files with 704 additions and 2 deletions
@@ -51,6 +51,7 @@ import eu.kanade.tachiyomi.data.track.Tracker
import eu.kanade.tachiyomi.data.track.TrackerManager
import eu.kanade.tachiyomi.data.track.anilist.AnilistApi
import eu.kanade.tachiyomi.data.track.bangumi.BangumiApi
import eu.kanade.tachiyomi.data.track.hikka.HikkaApi
import eu.kanade.tachiyomi.data.track.myanimelist.MyAnimeListApi
import eu.kanade.tachiyomi.data.track.shikimori.ShikimoriApi
import eu.kanade.tachiyomi.util.system.openInBrowser
@@ -166,6 +167,11 @@ object SettingsTrackingScreen : SearchableSettings {
login = { context.openInBrowser(BangumiApi.authUrl(), forceDefaultBrowser = true) },
logout = { dialog = LogoutDialog(trackerManager.bangumi) },
),
Preference.PreferenceItem.TrackerPreference(
tracker = trackerManager.hikka,
login = { context.openInBrowser(HikkaApi.authUrl(), forceDefaultBrowser = true) },
logout = { dialog = LogoutDialog(trackerManager.hikka) },
),
Preference.PreferenceItem.InfoPreference(stringResource(MR.strings.tracking_info)),
),
),
@@ -2,6 +2,7 @@ package eu.kanade.tachiyomi.data.track
import eu.kanade.tachiyomi.data.track.anilist.Anilist
import eu.kanade.tachiyomi.data.track.bangumi.Bangumi
import eu.kanade.tachiyomi.data.track.hikka.Hikka
import eu.kanade.tachiyomi.data.track.kavita.Kavita
import eu.kanade.tachiyomi.data.track.kitsu.Kitsu
import eu.kanade.tachiyomi.data.track.komga.Komga
@@ -28,8 +29,9 @@ class TrackerManager {
val mangaUpdates = MangaUpdates(7L)
val kavita = Kavita(KAVITA)
val suwayomi = Suwayomi(9L)
val hikka = Hikka(10L)
val trackers = listOf(myAnimeList, aniList, kitsu, shikimori, bangumi, komga, mangaUpdates, kavita, suwayomi)
val trackers = listOf(myAnimeList, aniList, kitsu, shikimori, bangumi, komga, mangaUpdates, kavita, suwayomi, hikka)
fun loggedInTrackers() = trackers.filter { it.isLoggedIn }
@@ -0,0 +1,171 @@
package eu.kanade.tachiyomi.data.track.hikka
import dev.icerock.moko.resources.StringResource
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.BaseTracker
import eu.kanade.tachiyomi.data.track.DeletableTracker
import eu.kanade.tachiyomi.data.track.hikka.dto.HKOAuth
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.serialization.json.Json
import tachiyomi.i18n.MR
import uy.kohesive.injekt.injectLazy
import tachiyomi.domain.track.model.Track as DomainTrack
class Hikka(id: Long) : BaseTracker(id, "Hikka"), DeletableTracker {
companion object {
const val READING = 0L
const val COMPLETED = 1L
const val ON_HOLD = 2L
const val DROPPED = 3L
const val PLAN_TO_READ = 4L
const val REREADING = 5L
private val SCORE_LIST = IntRange(0, 10)
.map(Int::toString)
.toImmutableList()
}
private val json: Json by injectLazy()
private val interceptor by lazy { HikkaInterceptor(this) }
private val api by lazy { HikkaApi(id, client, interceptor) }
override val supportsReadingDates: Boolean = true
override fun getLogo(): Int = R.drawable.brand_hikka
override fun getStatusList(): List<Long> {
return listOf(
READING,
COMPLETED,
ON_HOLD,
DROPPED,
PLAN_TO_READ,
REREADING,
)
}
override fun getStatus(status: Long): StringResource? = when (status) {
READING -> MR.strings.reading
PLAN_TO_READ -> MR.strings.plan_to_read
COMPLETED -> MR.strings.completed
ON_HOLD -> MR.strings.on_hold
DROPPED -> MR.strings.dropped
REREADING -> MR.strings.repeating
else -> null
}
override fun getReadingStatus(): Long = READING
override fun getRereadingStatus(): Long = REREADING
override fun getCompletionStatus(): Long = COMPLETED
override fun getScoreList(): ImmutableList<String> = SCORE_LIST
override fun displayScore(track: DomainTrack): String {
return track.score.toInt().toString()
}
override suspend fun update(
track: Track,
didReadChapter: Boolean,
): Track {
if (track.status != COMPLETED) {
if (didReadChapter) {
if (track.last_chapter_read.toLong() == track.total_chapters && track.total_chapters > 0) {
track.status = COMPLETED
track.finished_reading_date = System.currentTimeMillis()
} else if (track.status != REREADING) {
track.status = READING
if (track.last_chapter_read == 1.0) {
track.started_reading_date = System.currentTimeMillis()
}
}
}
}
return api.updateUserManga(track)
}
override suspend fun bind(track: Track, hasReadChapters: Boolean): Track {
val readContent = api.getRead(track)
val remoteTrack = api.getManga(track)
track.copyPersonalFrom(remoteTrack)
track.remote_id = remoteTrack.remote_id
track.library_id = remoteTrack.library_id
if (track.status != COMPLETED) {
val isRereading = track.status == REREADING
track.status = if (!isRereading && hasReadChapters) READING else track.status
}
return if (readContent != null) {
track.score = readContent.score.toDouble()
track.last_chapter_read = readContent.chapters.toDouble()
track.started_reading_date = (readContent.startDate ?: 0L) * 1000
track.finished_reading_date = (readContent.endDate ?: 0L) * 1000
update(track)
} else {
track.score = 0.0
update(track)
}
}
override suspend fun search(query: String): List<TrackSearch> = api.searchManga(query)
override suspend fun refresh(track: Track): Track {
val remoteTrack = api.getManga(track)
track.copyPersonalFrom(remoteTrack)
track.total_chapters = remoteTrack.total_chapters
val readContent = api.getRead(track)
if (readContent != null) {
track.score = readContent.score.toDouble()
track.last_chapter_read = readContent.chapters.toDouble()
track.status = toTrackStatus(readContent.status)
track.started_reading_date = (readContent.startDate ?: 0L) * 1000
track.finished_reading_date = (readContent.endDate ?: 0L) * 1000
}
return track
}
override suspend fun login(username: String, password: String) = login(password)
suspend fun login(reference: String) {
try {
val oauth = api.accessToken(reference)
interceptor.setAuth(oauth)
val user = api.getCurrentUser()
saveCredentials(user.reference, oauth.accessToken)
} catch (_: Throwable) {
logout()
}
}
override suspend fun delete(track: DomainTrack) = api.deleteUserManga(track)
override fun logout() {
super.logout()
trackPreferences.trackToken(this).delete()
interceptor.setAuth(null)
}
fun saveOAuth(oAuth: HKOAuth?) {
trackPreferences.trackToken(this).set(json.encodeToString(oAuth))
}
fun loadOAuth(): HKOAuth? {
return try {
json.decodeFromString<HKOAuth>(trackPreferences.trackToken(this).get())
} catch (_: Exception) {
null
}
}
}
@@ -0,0 +1,219 @@
package eu.kanade.tachiyomi.data.track.hikka
import android.net.Uri
import androidx.core.net.toUri
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.hikka.dto.HKManga
import eu.kanade.tachiyomi.data.track.hikka.dto.HKMangaPagination
import eu.kanade.tachiyomi.data.track.hikka.dto.HKOAuth
import eu.kanade.tachiyomi.data.track.hikka.dto.HKRead
import eu.kanade.tachiyomi.data.track.hikka.dto.HKUser
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.network.DELETE
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.PUT
import eu.kanade.tachiyomi.network.awaitSuccess
import eu.kanade.tachiyomi.network.jsonMime
import eu.kanade.tachiyomi.network.parseAs
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.add
import kotlinx.serialization.json.buildJsonArray
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import okhttp3.Headers
import okhttp3.OkHttpClient
import okhttp3.Request
import okhttp3.RequestBody.Companion.toRequestBody
import tachiyomi.core.common.util.lang.withIOContext
import uy.kohesive.injekt.injectLazy
import tachiyomi.domain.track.model.Track as DomainTrack
class HikkaApi(
private val trackId: Long,
private val client: OkHttpClient,
interceptor: HikkaInterceptor,
) {
suspend fun getCurrentUser(): HKUser {
return withIOContext {
val request = Request.Builder()
.url("${BASE_API_URL}/user/me")
.get()
.build()
with(json) {
authClient.newCall(request)
.awaitSuccess()
.parseAs<HKUser>()
}
}
}
suspend fun accessToken(reference: String): HKOAuth {
return withIOContext {
with(json) {
client.newCall(authTokenCreate(reference))
.awaitSuccess()
.parseAs<HKOAuth>()
}
}
}
suspend fun searchManga(query: String): List<TrackSearch> {
return withIOContext {
val url = "$BASE_API_URL/manga".toUri().buildUpon()
.appendQueryParameter("page", "1")
.appendQueryParameter("size", "50")
.build()
val payload = buildJsonObject {
put("media_type", buildJsonArray { })
put("status", buildJsonArray { })
put("only_translated", false)
put("magazines", buildJsonArray { })
put("genres", buildJsonArray { })
put(
"score",
buildJsonArray {
add(0)
add(10)
},
)
put("query", query)
put(
"sort",
buildJsonArray {
add("score:desc")
add("scored_by:desc")
},
)
}
with(json) {
authClient.newCall(POST(url.toString(), body = payload.toString().toRequestBody(jsonMime)))
.awaitSuccess()
.parseAs<HKMangaPagination>()
.list
.map { it.toTrack(trackId) }
}
}
}
suspend fun getRead(track: Track): HKRead? {
return withIOContext {
val slug = track.tracking_url.split("/")[4]
val url = "$BASE_API_URL/read/manga/$slug".toUri().buildUpon().build()
with(json) {
val response = authClient.newCall(GET(url.toString())).execute()
if (response.code == 404) {
return@withIOContext null
}
response.use {
it.parseAs<HKRead>()
}
}
}
}
suspend fun getManga(track: Track): TrackSearch {
return withIOContext {
val slug = track.tracking_url.split("/")[4]
val url = "$BASE_API_URL/manga/$slug".toUri().buildUpon()
.build()
with(json) {
authClient.newCall(GET(url.toString()))
.awaitSuccess()
.parseAs<HKManga>()
.toTrack(trackId)
}
}
}
suspend fun deleteUserManga(track: DomainTrack) {
return withIOContext {
val slug = track.remoteUrl.split("/")[4]
val url = "$BASE_API_URL/read/manga/$slug".toUri().buildUpon()
.build()
authClient.newCall(DELETE(url.toString()))
.awaitSuccess()
}
}
suspend fun addUserManga(track: Track): Track {
return withIOContext {
val slug = track.tracking_url.split("/")[4]
val url = "$BASE_API_URL/read/manga/$slug".toUri().buildUpon()
.build()
var rereads = getRead(track)?.rereads ?: 0
if (track.status == Hikka.REREADING && rereads == 0) {
rereads = 1
}
val payload = buildJsonObject {
put("note", "")
put("chapters", track.last_chapter_read.toInt())
put("volumes", 0)
put("rereads", rereads)
put("score", track.score.toInt())
put("status", track.toApiStatus())
put("start_date", if (track.started_reading_date > 0L) track.started_reading_date / 1000 else null)
put("end_date", if (track.finished_reading_date > 0L) track.finished_reading_date / 1000 else null)
}
with(json) {
authClient.newCall(PUT(url.toString(), body = payload.toString().toRequestBody(jsonMime)))
.awaitSuccess()
.parseAs<HKRead>()
.toTrack(trackId)
}
}
}
suspend fun updateUserManga(track: Track): Track = addUserManga(track)
private val json: Json by injectLazy()
private val authClient = client.newBuilder().addInterceptor(interceptor).build()
companion object {
const val BASE_API_URL = "https://api.hikka.io"
const val BASE_URL = "https://hikka.io"
private const val SCOPE = "readlist,read:user-details"
private const val CLIENT_REFERENCE = "598ef1f5-b9d2-4e66-8b65-06949d5e14fc"
private const val CLIENT_SECRET = "OKwzrNOZxq40psFgfcCUYddnvaeZWDnd34rt7fdcB5GmHoBBQuNTWX" +
"61sZs8KECEWVXtMUDtq8QC4t9WX4DwWWYLXEVlgnlUXGT1fWCb-18c" +
"Zd2m8Co-8HN6JQcjoP-B"
fun authUrl(): Uri = "$BASE_URL/oauth".toUri().buildUpon()
.appendQueryParameter("reference", CLIENT_REFERENCE)
.appendQueryParameter("scope", SCOPE)
.build()
fun refreshTokenRequest(accessToken: String): Request {
val headers = Headers.Builder()
.add("auth", accessToken)
.build()
return GET("$BASE_API_URL/user/me", headers = headers) // Any request with auth
}
fun authTokenCreate(reference: String): Request {
val payload = buildJsonObject {
put("request_reference", reference)
put("client_secret", CLIENT_SECRET)
}
return POST("$BASE_API_URL/auth/token", body = payload.toString().toRequestBody(jsonMime))
}
fun authTokenInfo(accessToken: String): Request {
val headers = Headers.Builder()
.add("auth", accessToken)
.build()
return GET("$BASE_API_URL/auth/token/info", headers = headers)
}
}
}
@@ -0,0 +1,48 @@
package eu.kanade.tachiyomi.data.track.hikka
import eu.kanade.tachiyomi.data.track.hikka.dto.HKAuthTokenInfo
import eu.kanade.tachiyomi.data.track.hikka.dto.HKOAuth
import kotlinx.serialization.json.Json
import okhttp3.Interceptor
import okhttp3.Response
import uy.kohesive.injekt.injectLazy
class HikkaInterceptor(private val hikka: Hikka) : Interceptor {
private val json: Json by injectLazy()
private var oauth: HKOAuth? = hikka.loadOAuth()
override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest = chain.request()
val currAuth = oauth ?: throw Exception("Hikka: You are not authorized")
if (currAuth.isExpired()) {
val refreshTokenResponse = chain.proceed(HikkaApi.refreshTokenRequest(currAuth.accessToken))
if (!refreshTokenResponse.isSuccessful) {
refreshTokenResponse.close()
hikka.logout()
throw Exception("Hikka: The token is expired")
}
val authTokenInfoResponse = chain.proceed(HikkaApi.authTokenInfo(currAuth.accessToken))
if (!authTokenInfoResponse.isSuccessful) {
authTokenInfoResponse.close()
}
val authTokenInfo = json.decodeFromString<HKAuthTokenInfo>(authTokenInfoResponse.body.string())
setAuth(HKOAuth(currAuth.accessToken, authTokenInfo.expiration, authTokenInfo.created))
}
val authRequest = originalRequest.newBuilder()
.addHeader("auth", currAuth.accessToken)
.addHeader("accept", "application/json")
.build()
return chain.proceed(authRequest)
}
fun setAuth(oauth: HKOAuth?) {
this.oauth = oauth
hikka.saveOAuth(oauth)
}
}
@@ -0,0 +1,28 @@
package eu.kanade.tachiyomi.data.track.hikka
import eu.kanade.tachiyomi.data.database.models.Track
import java.util.UUID
fun Track.toApiStatus() = when (status) {
Hikka.READING -> "reading"
Hikka.COMPLETED -> "completed"
Hikka.ON_HOLD -> "on_hold"
Hikka.DROPPED -> "dropped"
Hikka.PLAN_TO_READ -> "planned"
Hikka.REREADING -> "reading"
else -> throw NotImplementedError("Hikka: Unknown status: $status")
}
fun toTrackStatus(status: String) = when (status) {
"reading" -> Hikka.READING
"completed" -> Hikka.COMPLETED
"on_hold" -> Hikka.ON_HOLD
"dropped" -> Hikka.DROPPED
"planned" -> Hikka.PLAN_TO_READ
else -> throw NotImplementedError("Hikka: Unknown status: $status")
}
fun stringToNumber(input: String): Long {
val uuid = UUID.nameUUIDFromBytes(input.toByteArray())
return uuid.mostSignificantBits and Long.MAX_VALUE
}
@@ -0,0 +1,13 @@
package eu.kanade.tachiyomi.data.track.hikka.dto
import kotlinx.serialization.Serializable
@Serializable
data class HKAuthTokenInfo(
val reference: String,
val created: Long,
val client: HKClient,
val scope: List<String>,
val expiration: Long,
val used: Long,
)
@@ -0,0 +1,14 @@
package eu.kanade.tachiyomi.data.track.hikka.dto
import kotlinx.serialization.Serializable
@Serializable
data class HKClient(
val reference: String,
val name: String,
val description: String,
val verified: Boolean,
val user: HKUser,
val created: Long,
val updated: Long,
)
@@ -0,0 +1,69 @@
package eu.kanade.tachiyomi.data.track.hikka.dto
import eu.kanade.tachiyomi.data.track.hikka.HikkaApi
import eu.kanade.tachiyomi.data.track.hikka.stringToNumber
import eu.kanade.tachiyomi.data.track.hikka.toTrackStatus
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import java.text.SimpleDateFormat
import java.util.Locale
@Serializable
data class HKManga(
@SerialName("data_type")
val dataType: String,
@SerialName("title_original")
val titleOriginal: String,
@SerialName("media_type")
val mediaType: String,
@SerialName("title_ua")
val titleUa: String? = null,
@SerialName("title_en")
val titleEn: String? = null,
val chapters: Int? = null,
val volumes: Int? = null,
@SerialName("translated_ua")
val translatedUa: Boolean,
val status: String,
val image: String,
val year: Int? = null,
@SerialName("scored_by")
val scoredBy: Int,
val score: Double,
val slug: String,
@SerialName("start_date")
val startDate: Long? = null,
val read: List<HKRead>? = emptyList(),
) {
fun toTrack(trackId: Long): TrackSearch {
return TrackSearch.create(trackId).apply {
remote_id = stringToNumber(this@HKManga.slug)
title = this@HKManga.titleUa ?: this@HKManga.titleEn ?: this@HKManga.titleOriginal
total_chapters = this@HKManga.chapters?.toLong() ?: 0
cover_url = this@HKManga.image
score = this@HKManga.score
tracking_url = "${HikkaApi.BASE_URL}/manga/${this@HKManga.slug}"
publishing_status = this@HKManga.status
publishing_type = this@HKManga.mediaType
startDate?.takeIf { it != 0L }?.let {
val outputDf = SimpleDateFormat("yyyy-MM-dd", Locale.US)
start_date = try {
outputDf.format(it * 1000)
} catch (_: Exception) {
""
}
}
val userProgress = read?.firstOrNull()
if (userProgress != null) {
status = toTrackStatus(userProgress.status)
last_chapter_read = userProgress.chapters.toDouble()
score = userProgress.score.toDouble()
started_reading_date = (userProgress.startDate ?: 0L) * 1000
finished_reading_date = (userProgress.endDate ?: 0L) * 1000
}
}
}
}
@@ -0,0 +1,9 @@
package eu.kanade.tachiyomi.data.track.hikka.dto
import kotlinx.serialization.Serializable
@Serializable
data class HKMangaPagination(
val pagination: HKPagination,
val list: List<HKManga>,
)
@@ -0,0 +1,18 @@
package eu.kanade.tachiyomi.data.track.hikka.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class HKOAuth(
@SerialName("secret")
val accessToken: String,
val expiration: Long,
val created: Long,
) {
fun isExpired(): Boolean {
val currentTime = System.currentTimeMillis() / 1000
val buffer = 5 * 60 // safety margin
return currentTime >= (expiration - buffer)
}
}
@@ -0,0 +1,10 @@
package eu.kanade.tachiyomi.data.track.hikka.dto
import kotlinx.serialization.Serializable
@Serializable
data class HKPagination(
val total: Int,
val pages: Int,
val page: Int,
)
@@ -0,0 +1,46 @@
package eu.kanade.tachiyomi.data.track.hikka.dto
import eu.kanade.tachiyomi.data.track.hikka.HikkaApi
import eu.kanade.tachiyomi.data.track.hikka.stringToNumber
import eu.kanade.tachiyomi.data.track.hikka.toTrackStatus
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class HKRead(
val reference: String,
val note: String?,
val updated: Long,
val created: Long,
val status: String,
val chapters: Int,
val volumes: Int,
val rereads: Int,
val score: Int,
@SerialName("start_date")
val startDate: Long? = null,
@SerialName("end_date")
val endDate: Long? = null,
val content: HKManga? = null,
) {
fun toTrack(trackId: Long): TrackSearch {
return TrackSearch.create(trackId).apply {
val mangaContent = this@HKRead.content
if (mangaContent != null) {
title = mangaContent.titleUa ?: mangaContent.titleEn ?: mangaContent.titleOriginal
remote_id = stringToNumber(mangaContent.slug)
library_id = stringToNumber(mangaContent.slug)
total_chapters = mangaContent.chapters?.toLong() ?: 0
tracking_url = "${HikkaApi.BASE_URL}/manga/${mangaContent.slug}"
}
last_chapter_read = this@HKRead.chapters.toDouble()
score = this@HKRead.score.toDouble()
status = toTrackStatus(this@HKRead.status)
started_reading_date = startDate?.let { it * 1000 } ?: 0L
finished_reading_date = endDate?.let { it * 1000 } ?: 0L
}
}
}
@@ -0,0 +1,9 @@
package eu.kanade.tachiyomi.data.track.hikka.dto
import kotlinx.serialization.Serializable
@Serializable
data class HKUser(
val reference: String,
val username: String,
)
@@ -26,6 +26,7 @@ class TrackLoginActivity : BaseOAuthLoginActivity() {
"bangumi-auth" -> handleBangumi(data["code"])
"myanimelist-auth" -> handleMyAnimeList(data["code"])
"shikimori-auth" -> handleShikimori(data["code"])
"hikka-auth" -> handleHikka(data["reference"])
}
returnToSettings()
}
@@ -62,4 +63,12 @@ class TrackLoginActivity : BaseOAuthLoginActivity() {
trackerManager.shikimori.logout()
}
}
private suspend fun handleHikka(reference: String?) {
if (reference != null) {
trackerManager.hikka.login(reference)
} else {
trackerManager.hikka.logout()
}
}
}