Add Hikka tracker support (#1386)
Co-authored-by: AntsyLich <59261191+AntsyLich@users.noreply.github.com>
This commit is contained in:
@@ -12,6 +12,7 @@ The format is a modified version of [Keep a Changelog](https://keepachangelog.co
|
|||||||
|
|
||||||
## [Unreleased]
|
## [Unreleased]
|
||||||
### Added
|
### Added
|
||||||
|
- [Hikka](https://hikka.io/) tracker support ([@Lorg0n](https://github.com/Lorg0n)) ([#1386](https://github.com/mihonapp/mihon/pull/1386))
|
||||||
- Support resumable image downloads if supported by source ([@xMohnad](https://github.com/xMohnad)) ([#3167](https://github.com/mihonapp/mihon/pull/3167))
|
- Support resumable image downloads if supported by source ([@xMohnad](https://github.com/xMohnad)) ([#3167](https://github.com/mihonapp/mihon/pull/3167))
|
||||||
- Display authors and description in Shikimori search results ([@MajorTanya](https://github.com/MajorTanya)) ([#3499](https://github.com/mihonapp/mihon/pull/3499))
|
- Display authors and description in Shikimori search results ([@MajorTanya](https://github.com/MajorTanya)) ([#3499](https://github.com/mihonapp/mihon/pull/3499))
|
||||||
- Invalidate download cache after backup restore ([@leodyversemilla07](https://github.com/leodyversemilla07)) ([#3096](https://github.com/mihonapp/mihon/pull/3096))
|
- Invalidate download cache after backup restore ([@leodyversemilla07](https://github.com/leodyversemilla07)) ([#3096](https://github.com/mihonapp/mihon/pull/3096))
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ Discover and read manga, webtoons, comics, and more – easier than ever on your
|
|||||||
|
|
||||||
* Local reading of content.
|
* Local reading of content.
|
||||||
* A configurable reader with multiple viewers, reading directions and other settings.
|
* A configurable reader with multiple viewers, reading directions and other settings.
|
||||||
* Tracker support: [MyAnimeList](https://myanimelist.net/), [AniList](https://anilist.co/), [Kitsu](https://kitsu.app/), [MangaUpdates](https://mangaupdates.com), [Shikimori](https://shikimori.one), and [Bangumi](https://bgm.tv/) support.
|
* Tracker support: [MyAnimeList](https://myanimelist.net/), [AniList](https://anilist.co/), [Kitsu](https://kitsu.app/), [MangaUpdates](https://mangaupdates.com), [Shikimori](https://shikimori.one), [Bangumi](https://bgm.tv/) and [Hikka](https://hikka.io/) support.
|
||||||
* Categories to organize your library.
|
* Categories to organize your library.
|
||||||
* Light and dark themes.
|
* Light and dark themes.
|
||||||
* Schedule updating your library for new chapters.
|
* Schedule updating your library for new chapters.
|
||||||
|
|||||||
@@ -195,6 +195,7 @@
|
|||||||
<data android:host="bangumi-auth" />
|
<data android:host="bangumi-auth" />
|
||||||
<data android:host="myanimelist-auth" />
|
<data android:host="myanimelist-auth" />
|
||||||
<data android:host="shikimori-auth" />
|
<data android:host="shikimori-auth" />
|
||||||
|
<data android:host="hikka-auth" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
|
|
||||||
|
|||||||
+6
@@ -51,6 +51,7 @@ import eu.kanade.tachiyomi.data.track.Tracker
|
|||||||
import eu.kanade.tachiyomi.data.track.TrackerManager
|
import eu.kanade.tachiyomi.data.track.TrackerManager
|
||||||
import eu.kanade.tachiyomi.data.track.anilist.AnilistApi
|
import eu.kanade.tachiyomi.data.track.anilist.AnilistApi
|
||||||
import eu.kanade.tachiyomi.data.track.bangumi.BangumiApi
|
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.myanimelist.MyAnimeListApi
|
||||||
import eu.kanade.tachiyomi.data.track.shikimori.ShikimoriApi
|
import eu.kanade.tachiyomi.data.track.shikimori.ShikimoriApi
|
||||||
import eu.kanade.tachiyomi.util.system.openInBrowser
|
import eu.kanade.tachiyomi.util.system.openInBrowser
|
||||||
@@ -166,6 +167,11 @@ object SettingsTrackingScreen : SearchableSettings {
|
|||||||
login = { context.openInBrowser(BangumiApi.authUrl(), forceDefaultBrowser = true) },
|
login = { context.openInBrowser(BangumiApi.authUrl(), forceDefaultBrowser = true) },
|
||||||
logout = { dialog = LogoutDialog(trackerManager.bangumi) },
|
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)),
|
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.anilist.Anilist
|
||||||
import eu.kanade.tachiyomi.data.track.bangumi.Bangumi
|
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.kavita.Kavita
|
||||||
import eu.kanade.tachiyomi.data.track.kitsu.Kitsu
|
import eu.kanade.tachiyomi.data.track.kitsu.Kitsu
|
||||||
import eu.kanade.tachiyomi.data.track.komga.Komga
|
import eu.kanade.tachiyomi.data.track.komga.Komga
|
||||||
@@ -28,8 +29,9 @@ class TrackerManager {
|
|||||||
val mangaUpdates = MangaUpdates(7L)
|
val mangaUpdates = MangaUpdates(7L)
|
||||||
val kavita = Kavita(KAVITA)
|
val kavita = Kavita(KAVITA)
|
||||||
val suwayomi = Suwayomi(9L)
|
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 }
|
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"])
|
"bangumi-auth" -> handleBangumi(data["code"])
|
||||||
"myanimelist-auth" -> handleMyAnimeList(data["code"])
|
"myanimelist-auth" -> handleMyAnimeList(data["code"])
|
||||||
"shikimori-auth" -> handleShikimori(data["code"])
|
"shikimori-auth" -> handleShikimori(data["code"])
|
||||||
|
"hikka-auth" -> handleHikka(data["reference"])
|
||||||
}
|
}
|
||||||
returnToSettings()
|
returnToSettings()
|
||||||
}
|
}
|
||||||
@@ -62,4 +63,12 @@ class TrackLoginActivity : BaseOAuthLoginActivity() {
|
|||||||
trackerManager.shikimori.logout()
|
trackerManager.shikimori.logout()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private suspend fun handleHikka(reference: String?) {
|
||||||
|
if (reference != null) {
|
||||||
|
trackerManager.hikka.login(reference)
|
||||||
|
} else {
|
||||||
|
trackerManager.hikka.logout()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,29 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
xmlns:aapt="http://schemas.android.com/aapt"
|
||||||
|
android:width="192dp"
|
||||||
|
android:height="192dp"
|
||||||
|
android:viewportWidth="192"
|
||||||
|
android:viewportHeight="192">
|
||||||
|
<path
|
||||||
|
android:fillColor="#FF000000"
|
||||||
|
android:pathData="M0,0h192v192H0z"
|
||||||
|
android:strokeLineJoin="round"
|
||||||
|
android:fillType="evenOdd"/>
|
||||||
|
<path
|
||||||
|
android:pathData="m53.06,32.02 l-21.8,21.77a8.59,8.59 0,0 0,0.03 12.19l24.2,23.94a8.59,8.59 0,0 1,0.12 12.1l-24.46,25.13a8.59,8.59 0,0 0,0.18 12.17l22.08,21.38a8.59,8.59 0,0 0,12.02 -0.06l24.36,-24.09a8.6,8.6 0,0 1,12.12 0.03l23.98,23.94a8.59,8.59 0,0 0,12.11 0.03l22.03,-21.77c3.41,-3.38 3.4,-8.89 -0.03,-12.25l-24.26,-23.76c-3.44,-3.37 -3.44,-8.91 -0,-12.28l24.96,-24.47a8.59,8.59 0,0 0,0.03 -12.25l-22.64,-22.39a8.59,8.59 0,0 0,-12.2 0.12l-23.98,24.64a8.59,8.59 0,0 1,-12.2 0.12L65.18,31.99a8.6,8.6 0,0 0,-12.12 0.03"
|
||||||
|
android:strokeLineJoin="round"
|
||||||
|
android:fillType="nonZero">
|
||||||
|
<aapt:attr name="android:fillColor">
|
||||||
|
<gradient
|
||||||
|
android:startX="37.31"
|
||||||
|
android:startY="40.85"
|
||||||
|
android:endX="166.82"
|
||||||
|
android:endY="133.86"
|
||||||
|
android:type="linear">
|
||||||
|
<item android:offset="0" android:color="#FFD691F7"/>
|
||||||
|
<item android:offset="0.29" android:color="#FFD691F7"/>
|
||||||
|
<item android:offset="1" android:color="#FF226CBB"/>
|
||||||
|
</gradient>
|
||||||
|
</aapt:attr>
|
||||||
|
</path>
|
||||||
|
</vector>
|
||||||
Reference in New Issue
Block a user