Add MangaBaka tracker support (#3047)

* Add support for MangaBaka

Note: missing a client ID which should be added by the project owner.

* Fix typo causing token refresh to fail

The field is called `refresh_token` not `refreshToken`, so all
token refreshes would fail with a 401.

* Use user-preferred score type

Currently, this only set once on login. When selecting a different
score type on MangaBaka, users (currently) will have to log out and
log in again in order for Mihon to refetch the score setting.

This appears to be what Mihon's AniList implementation does as well,
so I followed that.

* Fix score preference

Didn't get caught during the rebase.

* Fix finish_date

* Fix dates appearing offset

Timezone shenanigans meant Mihon was sending an ISO 8601 string for
the UTC timezone which lead to issues in non-UTC timezones.

For example:
1. User in UTC+1 selects 2026-03-22
2. Date picker returns 2026-03-22T00:00:00+01:00
3. MangaBakaApi converts that to an instant, converting it to UTC:
  2026-03-21T23:00:00Z
4. MangaBaka receives that and sets all time components to 0
5. Mihon (when opening the track sheet next) receives:
  2026-03-21T00:00:00Z
6. Mihon renders that as 2026-03-21

I figured out that MangaBaka accepts date-only strings as well, so we
just send those now.

* Remove fetching of total_chapters from MangaBaka

Currently, this information is not what Mihon needs for automatic
marking as COMPLETED since MangaBaka considers this an ongoing tally
of volume-published chapters rather than the final chapter number of a
title.

If anyone other than me used this code: Probably will have to unlink
& relink MB entries to get rid of the total_chapters data, not sure.

MangaBaka is currently considering a request from me to potentially
change this, but currently it's not what we need.

* Fix start date being reset on each read chapter

Admittedly a silly oversight. The other date-supporting trackers (AL,
MAL, Kitsu) use this check as opposed to `>= 0.0`.

* Add id: prefix search for MangaBaka

* Use new /v1/my/profile endpoint

Kindly added by the developers after my request <3.

* Change tracker ID to 11

* Remove unused import

* Identify Mihon in UA on all requests

* Use new `published.start_date` field for search

* Change Mihon's User Agent string for MangaBaka

- use MR.strings.app_name to avoid manual work for forks
- include app ID & commit sha for further distinction

* Add Changelog entry

* Add Mihon client ID

* Add OAuth state query param

This is?/will be? required by MangaBaka's auth provider and caused
linking issues for anyone who tried to link with MB in the past two
weeks or so.

* Update MangaBaka API URL

Changed as per MB announcement from 2026-05-27

* Use `titles` instead of old `title`

* Make title selection a method on MangaBakaItem

* Include ID in fallback title for easier reporting

Makes it a lot more intuitive for users.
Also reworded the message to more clearly direct users to the
Mangabaka Discord server instead of sounding like an instruction to
contact some "mangabaka" user in the Mihon Discord.

* `titles` is nullable

At least I noticed this before it was merged...

* Fix MangaBaka brand name

Co-authored-by: BrutuZ <7104931+BrutuZ@users.noreply.github.com>

* Remove Hikka comment

* Put MangaBaka at the top of the tracker list

AntsyLich decreed it to be like this.

Co-authored-by: AntsyLich <59261191+AntsyLich@users.noreply.github.com>

* Add display name support from #3533

* Fix changelog

* Rename MB icon to brand_mangabaka.webp

---------

Co-authored-by: BrutuZ <7104931+BrutuZ@users.noreply.github.com>
Co-authored-by: AntsyLich <59261191+AntsyLich@users.noreply.github.com>
This commit is contained in:
MajorTanya
2026-07-07 16:58:50 +02:00
committed by GitHub
parent 0787678c12
commit 41f4579c74
17 changed files with 793 additions and 1 deletions
@@ -3,6 +3,7 @@ package eu.kanade.domain.track.service
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.mangabaka.MangaBaka
import tachiyomi.core.common.preference.Preference
import tachiyomi.core.common.preference.PreferenceStore
import tachiyomi.core.common.preference.getEnum
@@ -41,6 +42,8 @@ class TrackPreferences(
val anilistScoreType: Preference<String> = preferenceStore.getString("anilist_score_type", Anilist.POINT_10)
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 autoUpdateTrackOnMarkRead: Preference<AutoTrackState> = preferenceStore.getEnum(
@@ -52,6 +52,7 @@ 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.mangabaka.MangaBakaApi
import eu.kanade.tachiyomi.data.track.myanimelist.MyAnimeListApi
import eu.kanade.tachiyomi.data.track.shikimori.ShikimoriApi
import eu.kanade.tachiyomi.util.system.openInBrowser
@@ -137,6 +138,11 @@ object SettingsTrackingScreen : SearchableSettings {
Preference.PreferenceGroup(
title = stringResource(MR.strings.services),
preferenceItems = listOf(
Preference.PreferenceItem.TrackerPreference(
tracker = trackerManager.mangaBaka,
login = { context.openInBrowser(MangaBakaApi.authUrl(), forceDefaultBrowser = true) },
logout = { dialog = LogoutDialog(trackerManager.mangaBaka) },
),
Preference.PreferenceItem.TrackerPreference(
tracker = trackerManager.myAnimeList,
login = { context.openInBrowser(MyAnimeListApi.authUrl(), forceDefaultBrowser = true) },
@@ -6,6 +6,7 @@ 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
import eu.kanade.tachiyomi.data.track.mangabaka.MangaBaka
import eu.kanade.tachiyomi.data.track.mangaupdates.MangaUpdates
import eu.kanade.tachiyomi.data.track.myanimelist.MyAnimeList
import eu.kanade.tachiyomi.data.track.shikimori.Shikimori
@@ -18,6 +19,7 @@ class TrackerManager {
const val ANILIST = 2L
const val KITSU = 3L
const val KAVITA = 8L
const val MANGABAKA = 11L
}
val myAnimeList = MyAnimeList(1L)
@@ -30,8 +32,21 @@ class TrackerManager {
val kavita = Kavita(KAVITA)
val suwayomi = Suwayomi(9L)
val hikka = Hikka(10L)
val mangaBaka = MangaBaka(MANGABAKA)
val trackers = listOf(myAnimeList, aniList, kitsu, shikimori, bangumi, komga, mangaUpdates, kavita, suwayomi, hikka)
val trackers = listOf(
myAnimeList,
aniList,
kitsu,
shikimori,
bangumi,
komga,
mangaUpdates,
kavita,
suwayomi,
hikka,
mangaBaka,
)
fun loggedInTrackers() = trackers.filter { it.isLoggedIn }
@@ -0,0 +1,196 @@
package eu.kanade.tachiyomi.data.track.mangabaka
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.mangabaka.dto.MangaBakaOAuth
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 MangaBaka(id: Long) : BaseTracker(id, "MangaBaka"), DeletableTracker {
private val json: Json by injectLazy()
private val interceptor by lazy { MangaBakaInterceptor(this) }
private val api by lazy { MangaBakaApi(id, client, interceptor) }
override val supportsReadingDates: Boolean = true
override val supportsPrivateTracking: Boolean = true
private val scorePreference = trackPreferences.mangabakaScoreType
override fun getLogo(): Int = R.drawable.brand_mangabaka
override fun getStatusList(): List<Long> {
return listOf(READING, COMPLETED, PAUSED, DROPPED, PLAN_TO_READ, REREADING, CONSIDERING)
}
override fun getStatus(status: Long): StringResource? = when (status) {
CONSIDERING -> MR.strings.considering
COMPLETED -> MR.strings.completed
DROPPED -> MR.strings.dropped
PAUSED -> MR.strings.paused
PLAN_TO_READ -> MR.strings.plan_to_read
READING -> MR.strings.reading
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> {
return when (scorePreference.get()) {
// 1, 2, ..., 99, 100
STEP_1 -> IntRange(0, 100).map(Int::toString).toImmutableList()
// 5, 10, ..., 95, 100
STEP_5 -> IntRange(0, 100).step(5).map(Int::toString).toImmutableList()
// 10, 20, ..., 90, 100
STEP_10 -> IntRange(0, 100).step(10).map(Int::toString).toImmutableList()
// 20, 40, ..., 80, 100
STEP_20 -> IntRange(0, 100).step(20).map(Int::toString).toImmutableList()
// 25, 50, 75, 100
STEP_25 -> IntRange(0, 100).step(25).map(Int::toString).toImmutableList()
else -> throw Exception("Unknown score type")
}
}
override fun displayScore(track: DomainTrack): String = track.score.toInt().toString()
override suspend fun update(
track: Track,
didReadChapter: Boolean,
): Track {
if (track.status != COMPLETED && didReadChapter) {
if (track.total_chapters > 0 && track.last_chapter_read.toLong() == track.total_chapters) {
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.updateLibManga(track)
}
override suspend fun bind(
track: Track,
hasReadChapters: Boolean,
): Track {
val remoteTrack = api.findLibManga(track)
return if (remoteTrack != null) {
track.copyPersonalFrom(remoteTrack, copyRemotePrivate = false)
track.title = remoteTrack.title
track.remote_id = remoteTrack.remote_id
if (track.status != COMPLETED) {
val isRereading = track.status == REREADING
track.status = if (!isRereading && hasReadChapters) READING else track.status
}
update(track)
} else {
// Set default fields if it's not found in the list
track.status = if (hasReadChapters) READING else PLAN_TO_READ
track.score = 0.0
api.addLibManga(track)
}
}
override suspend fun search(query: String): List<TrackSearch> {
if (query.startsWith(SEARCH_ID_PREFIX)) {
query.substringAfter(SEARCH_ID_PREFIX).toIntOrNull()?.let { id ->
return api.getMangaDetails(id)?.let { listOf(it) } ?: emptyList()
}
}
return api.search(query)
}
override suspend fun refresh(track: Track): Track {
val remoteTrack = api.findLibManga(track) ?: throw Exception("Could not find manga")
track.copyPersonalFrom(remoteTrack)
track.remote_id = remoteTrack.remote_id
track.title = remoteTrack.title
return track
}
override suspend fun login(username: String, password: String) = login(password)
suspend fun login(code: String) {
try {
val oauth = api.getAccessToken(code)
interceptor.setAuth(oauth)
val currentUser = api.getCurrentUser()
val scoreType = when (currentUser.ratingSteps) {
1 -> STEP_1
5 -> STEP_5
10 -> STEP_10
20 -> STEP_20
25 -> STEP_25
else -> throw Exception("Unknown score step size ${currentUser.ratingSteps}")
}
scorePreference.set(scoreType)
saveDisplayUsername(currentUser.nickname ?: currentUser.preferredUsername ?: currentUser.id)
saveCredentials("user", oauth.accessToken)
} catch (_: Exception) {
logout()
}
}
fun saveToken(oauth: MangaBakaOAuth?) {
trackPreferences.trackToken(this).set(json.encodeToString(oauth))
}
fun restoreToken(): MangaBakaOAuth? {
return try {
json.decodeFromString(trackPreferences.trackToken(this).get())
} catch (_: Exception) {
null
}
}
fun verifyOAuthState(state: String): Boolean = api.verifyOAuthState(state)
override fun logout() {
super.logout()
trackPreferences.trackToken(this).delete()
interceptor.setAuth(null)
}
override suspend fun delete(track: DomainTrack) {
api.deleteLibManga(track)
}
companion object {
const val READING = 1L
const val COMPLETED = 2L
const val PAUSED = 3L
const val DROPPED = 4L
const val PLAN_TO_READ = 5L
const val REREADING = 6L
const val CONSIDERING = 7L
const val STEP_1 = "STEP_1"
const val STEP_5 = "STEP_5"
const val STEP_10 = "STEP_10"
const val STEP_20 = "STEP_20"
const val STEP_25 = "STEP_25"
private const val SEARCH_ID_PREFIX = "id:"
}
}
@@ -0,0 +1,327 @@
package eu.kanade.tachiyomi.data.track.mangabaka
import android.net.Uri
import androidx.core.net.toUri
import eu.kanade.tachiyomi.BuildConfig
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.TrackerManager
import eu.kanade.tachiyomi.data.track.mangabaka.dto.MangaBakaItem
import eu.kanade.tachiyomi.data.track.mangabaka.dto.MangaBakaItemResult
import eu.kanade.tachiyomi.data.track.mangabaka.dto.MangaBakaListResult
import eu.kanade.tachiyomi.data.track.mangabaka.dto.MangaBakaOAuth
import eu.kanade.tachiyomi.data.track.mangabaka.dto.MangaBakaSearchResult
import eu.kanade.tachiyomi.data.track.mangabaka.dto.MangaBakaUserProfile
import eu.kanade.tachiyomi.data.track.mangabaka.dto.MangaBakaUserProfileResponse
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.HttpException
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.PUT
import eu.kanade.tachiyomi.network.awaitSuccess
import eu.kanade.tachiyomi.network.parseAs
import eu.kanade.tachiyomi.util.PkceUtil
import eu.kanade.tachiyomi.util.lang.toLocalDate
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put
import okhttp3.FormBody
import okhttp3.Headers.Companion.headersOf
import okhttp3.OkHttpClient
import okhttp3.RequestBody.Companion.toRequestBody
import tachiyomi.core.common.util.lang.withIOContext
import tachiyomi.i18n.MR
import uy.kohesive.injekt.injectLazy
import java.math.RoundingMode
import java.security.SecureRandom
import java.util.Base64
import java.util.Locale
import kotlin.time.Instant
import tachiyomi.domain.track.model.Track as DomainTrack
class MangaBakaApi(
private val trackId: Long,
baseClient: OkHttpClient,
interceptor: MangaBakaInterceptor,
) {
private val json: Json by injectLazy()
private val client = baseClient.newBuilder().addInterceptor {
it.request().newBuilder()
.header(
"User-Agent",
buildString {
append("${MR.strings.app_name}/v${BuildConfig.VERSION_NAME} ")
append("(${BuildConfig.APPLICATION_ID} ${BuildConfig.COMMIT_SHA}) ")
append("(Android) (https://github.com/mihonapp/mihon)")
},
)
.build()
.let(it::proceed)
}.build()
private val authClient = client.newBuilder().addInterceptor(interceptor).build()
suspend fun addLibManga(track: Track): Track {
return withIOContext {
val url = "$LIBRARY_API_URL/${track.remote_id}"
val body = buildJsonObject {
put("is_private", track.private)
put("state", track.toApiStatus())
if (track.last_chapter_read > 0.0) {
put("progress_chapter", track.last_chapter_read)
}
if (track.score > 0) {
put("rating", track.score.toInt().coerceIn(0, 100))
}
if (track.started_reading_date > 0) {
put("start_date", track.started_reading_date.toLocalDate().toString())
}
if (track.finished_reading_date > 0) {
put("finish_date", track.finished_reading_date.toLocalDate().toString())
}
}
.toString()
.toRequestBody()
authClient
.newCall(POST(url, body = body, headers = headersOf("Content-Type", APP_JSON)))
.awaitSuccess()
// only returns 201 with the body { "status": 201, "data": true }, so no library ID for us
track
}
}
suspend fun deleteLibManga(track: DomainTrack) {
withIOContext {
val url = "$LIBRARY_API_URL/${track.remoteId}"
authClient
.newCall(DELETE(url))
.awaitSuccess()
}
}
suspend fun findLibManga(track: Track): Track? {
return withIOContext {
with(json) {
try {
val url = "$LIBRARY_API_URL/${track.remote_id}"
val userData = authClient.newCall(GET(url))
.awaitSuccess()
.parseAs<MangaBakaListResult>()
.data
val additionalData = authClient.newCall(GET("$API_BASE_URL/v1/series/${track.remote_id}"))
.awaitSuccess()
.parseAs<MangaBakaItemResult>()
.data
Track.create(TrackerManager.MANGABAKA).apply {
remote_id = track.remote_id
title = additionalData.chooseBestTitle()
status = userData.getStatus()
score = userData.rating?.toDouble() ?: 0.0
started_reading_date = userData.startDate?.let { Instant.parse(it).toEpochMilliseconds() } ?: 0
finished_reading_date =
userData.finishDate?.let { Instant.parse(it).toEpochMilliseconds() } ?: 0
last_chapter_read = userData.progressChapter ?: 0.0
private = userData.isPrivate
}
} catch (e: HttpException) {
if (e.code == 404) {
null
} else {
throw e
}
}
}
}
}
suspend fun updateLibManga(track: Track): Track {
return withIOContext {
val url = "$LIBRARY_API_URL/${track.remote_id}"
val body = buildJsonObject {
put("state", track.toApiStatus())
put("is_private", track.private)
if (track.last_chapter_read > 0.0) {
put("progress_chapter", track.last_chapter_read)
} else {
put("progress_chapter", null)
}
if (track.score > 0) {
put("rating", track.score.toInt().coerceIn(0, 100))
} else {
put("rating", null)
}
if (track.started_reading_date > 0) {
put("start_date", track.started_reading_date.toLocalDate().toString())
} else {
put("start_date", null)
}
if (track.finished_reading_date > 0) {
put("finish_date", track.finished_reading_date.toLocalDate().toString())
} else {
put("finish_date", null)
}
}
.toString()
.toRequestBody()
authClient
.newCall(PUT(url, body = body, headers = headersOf("Content-Type", APP_JSON)))
.awaitSuccess()
track
}
}
suspend fun search(search: String): List<TrackSearch> {
return withIOContext {
val url = "$API_BASE_URL/v1/series/search".toUri().buildUpon()
.appendQueryParameter("q", search)
.appendQueryParameter("type_not", "novel")
.build()
with(json) {
client.newCall(GET(url.toString()))
.awaitSuccess()
.parseAs<MangaBakaSearchResult>()
.data
.map { parseSearchItem(it) }
}
}
}
private fun parseSearchItem(item: MangaBakaItem): TrackSearch {
return TrackSearch.create(trackId).apply {
remote_id = item.id
title = item.chooseBestTitle()
summary = item.description?.trim().orEmpty()
score = item.rating?.toBigDecimal()?.setScale(2, RoundingMode.HALF_UP)?.toDouble() ?: -1.0
cover_url = item.cover.x250.x1.orEmpty()
tracking_url = "$BASE_URL/${item.id}"
start_date = item.published.startDate.orEmpty()
publishing_status = item.status
publishing_type = item.type.replaceFirstChar { c ->
if (c.isLowerCase()) c.titlecase(Locale.getDefault()) else c.toString()
}
authors = item.authors.orEmpty()
artists = item.artists.orEmpty()
}
}
suspend fun getMangaDetails(id: Int): TrackSearch? {
return withIOContext {
val url = "$API_BASE_URL/v1/series".toUri().buildUpon()
.appendPath(id.toString())
.build()
with(json) {
try {
authClient.newCall(GET(url.toString()))
.awaitSuccess()
.parseAs<MangaBakaItemResult>()
.data
.let { parseSearchItem(it) }
} catch (e: HttpException) {
if (e.code == 404) {
return@with null
}
throw e
}
}
}
}
suspend fun getCurrentUser(): MangaBakaUserProfile {
return withIOContext {
with(json) {
authClient.newCall(GET("$API_BASE_URL/v1/my/profile"))
.awaitSuccess()
.parseAs<MangaBakaUserProfileResponse>()
.data
}
}
}
suspend fun getAccessToken(code: String): MangaBakaOAuth {
return withIOContext {
val formBody = FormBody.Builder()
.add("client_id", CLIENT_ID)
.add("code", code)
.add("code_verifier", codeVerifier)
.add("code_challenge_method", "S256")
.add("grant_type", "authorization_code")
.add("redirect_uri", REDIRECT_URI)
.add("scope", SCOPES)
.build()
with(json) {
client.newCall(POST("${OAUTH_URL}/token", body = formBody))
.awaitSuccess().parseAs()
}
}
}
fun verifyOAuthState(state: String): Boolean = state == oauthStateParam
companion object {
private const val CLIENT_ID = "zEZYMHXLWsLsafgbvJHXqzGvqQNOdkpo"
private const val BASE_URL = "https://mangabaka.org"
private const val API_BASE_URL = "https://api.mangabaka.org"
private const val LIBRARY_API_URL = "$API_BASE_URL/v1/my/library"
private const val OAUTH_URL = "$BASE_URL/auth/oauth2"
private const val SCOPES = "library.read library.write offline_access openid"
private const val REDIRECT_URI = "mihon://mangabaka-auth"
private const val APP_JSON = "application/json"
private var codeVerifier: String = ""
private var oauthStateParam: String = ""
fun authUrl(): Uri = "$OAUTH_URL/authorize".toUri().buildUpon() //
.appendQueryParameter("client_id", CLIENT_ID)
.appendQueryParameter("code_challenge", getPkceS256ChallengeCode())
.appendQueryParameter("code_challenge_method", "S256")
.appendQueryParameter("response_type", "code")
.appendQueryParameter("scope", SCOPES)
.appendQueryParameter("redirect_uri", REDIRECT_URI)
.appendQueryParameter("state", getOAuthStateParam())
.build()
fun refreshTokenRequest(token: String) = POST(
"$OAUTH_URL/token",
body = FormBody.Builder()
.add("grant_type", "refresh_token")
.add("client_id", CLIENT_ID)
.add("refresh_token", token)
.add("redirect_uri", REDIRECT_URI)
.build(),
)
private fun getOAuthStateParam(): String {
val bytes = ByteArray(16)
SecureRandom().nextBytes(bytes)
oauthStateParam = Base64.getUrlEncoder()
.withoutPadding()
.encodeToString(bytes)
return oauthStateParam
}
private fun getPkceS256ChallengeCode(): String {
// MangaBaka requires an actually conformant PKCE process, unlike MAL
// 1. create verifier
// 2. create challenge from verifier (S256 hash -> base64 URL encode)
// 3. send challenge to /authorize
// 4. send verifier for access tokens to /token
val codes = PkceUtil.generateS256Codes()
codeVerifier = codes.codeVerifier
return codes.codeChallenge
}
}
}
@@ -0,0 +1,41 @@
package eu.kanade.tachiyomi.data.track.mangabaka
import eu.kanade.tachiyomi.data.track.mangabaka.dto.MangaBakaOAuth
import kotlinx.serialization.json.Json
import okhttp3.Interceptor
import okhttp3.Response
import uy.kohesive.injekt.injectLazy
class MangaBakaInterceptor(private val mangaBaka: MangaBaka) : Interceptor {
private val json: Json by injectLazy()
private var oauth: MangaBakaOAuth? = mangaBaka.restoreToken()
override fun intercept(chain: Interceptor.Chain): Response {
val originalRequest = chain.request()
var currentAuth = oauth ?: throw Exception("Not authenticated with MangaBaka")
if (currentAuth.isExpired()) {
val response = chain.proceed(MangaBakaApi.refreshTokenRequest(currentAuth.refreshToken))
if (response.isSuccessful) {
currentAuth = json.decodeFromString(response.body.string())
setAuth(currentAuth)
} else {
response.close()
}
}
return originalRequest.newBuilder()
.addHeader("Authorization", "Bearer ${currentAuth.accessToken}")
.build()
.let(chain::proceed)
}
fun setAuth(oauth: MangaBakaOAuth?) {
this.oauth = oauth
mangaBaka.saveToken(oauth)
}
}
@@ -0,0 +1,14 @@
package eu.kanade.tachiyomi.data.track.mangabaka
import eu.kanade.tachiyomi.data.database.models.Track
fun Track.toApiStatus() = when (status) {
MangaBaka.CONSIDERING -> "considering"
MangaBaka.COMPLETED -> "completed"
MangaBaka.DROPPED -> "dropped"
MangaBaka.PAUSED -> "paused"
MangaBaka.PLAN_TO_READ -> "plan_to_read"
MangaBaka.READING -> "reading"
MangaBaka.REREADING -> "rereading"
else -> throw NotImplementedError("Unknown status: $status")
}
@@ -0,0 +1,76 @@
package eu.kanade.tachiyomi.data.track.mangabaka.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
private val TITLE_PRIORITIES = listOf("en", "ja-Latn", "ja", "ko-Latn", "ko", "zh-Latn", "zh")
@Serializable
data class MangaBakaItemResult(
val data: MangaBakaItem,
)
@Serializable
data class MangaBakaSearchResult(
val data: List<MangaBakaItem>,
)
@Serializable
data class MangaBakaItem(
val id: Long,
val cover: MangaBakaCover,
val authors: List<String>?,
val artists: List<String>?,
val description: String?,
val published: MangaBakaPublishData,
val status: String,
val type: String,
val rating: Double?,
val titles: List<MangaBakaItemTitle>?,
) {
fun chooseBestTitle(): String {
// based on https://mangabaka.org/pages/announcements/15-titles-v2#finding-the-title-you-want
// extended with zh-Latn and zh
val bestTitlePerLanguage = TITLE_PRIORITIES.associateWith { lang ->
titles?.filter { it.language == lang }
?.minByOrNull {
when {
it.isPrimary -> 0
"official" in it.traits -> 1
"native" in it.traits -> 2
else -> 3
}
}
}
return TITLE_PRIORITIES
.firstNotNullOfOrNull { bestTitlePerLanguage[it]?.title }
?: titles?.firstOrNull()?.title
?: "ID: $id - Could not find name! (report on the MangaBaka Discord)"
}
}
@Serializable
data class MangaBakaCover(
val x250: MangaBakaScaledCover,
)
@Serializable
data class MangaBakaScaledCover(
val x1: String?,
)
@Serializable
data class MangaBakaPublishData(
@SerialName("start_date")
val startDate: String?,
)
@Serializable
data class MangaBakaItemTitle(
val language: String,
val traits: List<String>,
val title: String,
@SerialName("is_primary")
val isPrimary: Boolean,
)
@@ -0,0 +1,35 @@
package eu.kanade.tachiyomi.data.track.mangabaka.dto
import eu.kanade.tachiyomi.data.track.mangabaka.MangaBaka
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class MangaBakaListResult(
val data: MangaBakaListEntry,
)
@Serializable
data class MangaBakaListEntry(
val state: String,
@SerialName("start_date")
val startDate: String?,
@SerialName("finish_date")
val finishDate: String?,
@SerialName("is_private")
val isPrivate: Boolean,
@SerialName("progress_chapter")
val progressChapter: Double?,
val rating: Long?,
) {
fun getStatus(): Long = when (state) {
"considering" -> MangaBaka.CONSIDERING
"completed" -> MangaBaka.COMPLETED
"dropped" -> MangaBaka.DROPPED
"paused" -> MangaBaka.PAUSED
"plan_to_read" -> MangaBaka.PLAN_TO_READ
"reading" -> MangaBaka.READING
"rereading" -> MangaBaka.REREADING
else -> throw NotImplementedError("Unknown status: $state")
}
}
@@ -0,0 +1,23 @@
package eu.kanade.tachiyomi.data.track.mangabaka.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
import kotlin.time.Clock
import kotlin.time.Duration.Companion.minutes
@Serializable
data class MangaBakaOAuth(
@SerialName("access_token")
val accessToken: String,
@SerialName("refresh_token")
val refreshToken: String,
@SerialName("expires_in")
val expiresIn: Long,
@SerialName("expires_at")
val expiresAt: Long,
@SerialName("token_type")
val tokenType: String,
val scope: String,
) {
fun isExpired(): Boolean = Clock.System.now().plus(1.minutes).epochSeconds > expiresAt
}
@@ -0,0 +1,20 @@
package eu.kanade.tachiyomi.data.track.mangabaka.dto
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class MangaBakaUserProfileResponse(
val data: MangaBakaUserProfile,
)
@Serializable
data class MangaBakaUserProfile(
// incomplete DTO since this is the only part we need
val id: String,
@SerialName("rating_steps")
val ratingSteps: Int,
val nickname: String?,
@SerialName("preferred_username")
val preferredUsername: String?,
)
@@ -3,6 +3,8 @@ package eu.kanade.tachiyomi.ui.setting.track
import android.net.Uri
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.launch
import logcat.LogPriority
import tachiyomi.core.common.util.system.logcat
class TrackLoginActivity : BaseOAuthLoginActivity() {
@@ -24,6 +26,7 @@ class TrackLoginActivity : BaseOAuthLoginActivity() {
when (uri.host) {
"anilist-auth" -> handleAniList(data["access_token"])
"bangumi-auth" -> handleBangumi(data["code"])
"mangabaka-auth" -> handleMangaBaka(data["code"], data["state"])
"myanimelist-auth" -> handleMyAnimeList(data["code"])
"shikimori-auth" -> handleShikimori(data["code"])
"hikka-auth" -> handleHikka(data["reference"])
@@ -48,6 +51,22 @@ class TrackLoginActivity : BaseOAuthLoginActivity() {
}
}
private suspend fun handleMangaBaka(code: String?, state: String?) {
if (state == null) {
logcat(LogPriority.WARN) { "Did not receive state parameter from MangaBaka OAuth" }
return
}
if (code != null) {
if (!trackerManager.mangaBaka.verifyOAuthState(state)) {
logcat(LogPriority.WARN) { "Received wrong OAuth state back from MangaBaka" }
return
}
trackerManager.mangaBaka.login(code)
} else {
trackerManager.mangaBaka.logout()
}
}
private suspend fun handleMyAnimeList(code: String?) {
if (code != null) {
trackerManager.myAnimeList.login(code)
@@ -1,5 +1,6 @@
package eu.kanade.tachiyomi.util
import java.security.MessageDigest
import java.security.SecureRandom
import java.util.Base64
@@ -12,4 +13,17 @@ object PkceUtil {
.withoutPadding()
.encodeToString(codeVerifier)
}
fun generateS256Codes(): PkceCodes {
val codeVerifier = generateCodeVerifier()
val digest = MessageDigest.getInstance("SHA-256").digest(codeVerifier.toByteArray())
val codeChallenge = Base64.getUrlEncoder().withoutPadding().encodeToString(digest)
return PkceCodes(codeVerifier = codeVerifier, codeChallenge = codeChallenge)
}
}
data class PkceCodes(
val codeVerifier: String,
val codeChallenge: String,
)