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:
@@ -14,6 +14,7 @@ The format is a modified version of [Keep a Changelog](https://keepachangelog.co
|
||||
### Added
|
||||
- [Hikka](https://hikka.io/) tracker support ([@Lorg0n](https://github.com/Lorg0n)) ([#1386](https://github.com/mihonapp/mihon/pull/1386))
|
||||
- Fix Hikka not defaulting to "Plan to Read" for unread titles ([@MajorTanya](https://github.com/MajorTanya)) ([#3534](https://github.com/mihonapp/mihon/pull/3534))
|
||||
- Add support for [MangaBaka](https://mangabaka.org) tracker ([@MajorTanya](https://github.com/MajorTanya)) ([#3047](https://github.com/mihonapp/mihon/pull/3047))
|
||||
- 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))
|
||||
- Invalidate download cache after backup restore ([@leodyversemilla07](https://github.com/leodyversemilla07)) ([#3096](https://github.com/mihonapp/mihon/pull/3096))
|
||||
|
||||
@@ -193,6 +193,7 @@
|
||||
|
||||
<data android:host="anilist-auth" />
|
||||
<data android:host="bangumi-auth" />
|
||||
<data android:host="mangabaka-auth" />
|
||||
<data android:host="myanimelist-auth" />
|
||||
<data android:host="shikimori-auth" />
|
||||
<data android:host="hikka-auth" />
|
||||
|
||||
@@ -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(
|
||||
|
||||
+6
@@ -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
|
||||
}
|
||||
+20
@@ -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,
|
||||
)
|
||||
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 17 KiB |
@@ -786,6 +786,7 @@
|
||||
<string name="on_hold">On hold</string>
|
||||
<string name="paused">Paused</string>
|
||||
<string name="plan_to_read">Plan to read</string>
|
||||
<string name="considering">Considering</string>
|
||||
<string name="repeating">Rereading</string>
|
||||
<string name="reading_list">Reading List</string>
|
||||
<string name="wish_list">Wish List</string>
|
||||
|
||||
Reference in New Issue
Block a user