From 41f4579c742059075d695356c5b5484546662719 Mon Sep 17 00:00:00 2001 From: MajorTanya <39014446+MajorTanya@users.noreply.github.com> Date: Tue, 7 Jul 2026 16:58:50 +0200 Subject: [PATCH] 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> --- CHANGELOG.md | 1 + app/src/main/AndroidManifest.xml | 1 + .../domain/track/service/TrackPreferences.kt | 3 + .../settings/screen/SettingsTrackingScreen.kt | 6 + .../tachiyomi/data/track/TrackerManager.kt | 17 +- .../data/track/mangabaka/MangaBaka.kt | 196 +++++++++++ .../data/track/mangabaka/MangaBakaApi.kt | 327 ++++++++++++++++++ .../track/mangabaka/MangaBakaInterceptor.kt | 41 +++ .../data/track/mangabaka/MangaBakaUtils.kt | 14 + .../data/track/mangabaka/dto/MangaBakaItem.kt | 76 ++++ .../track/mangabaka/dto/MangaBakaListEntry.kt | 35 ++ .../track/mangabaka/dto/MangaBakaOAuth.kt | 23 ++ .../mangabaka/dto/MangaBakaUserProfile.kt | 20 ++ .../ui/setting/track/TrackLoginActivity.kt | 19 + .../java/eu/kanade/tachiyomi/util/PkceUtil.kt | 14 + .../res/drawable-nodpi/brand_mangabaka.webp | Bin 0 -> 17572 bytes .../moko-resources/base/strings.xml | 1 + 17 files changed, 793 insertions(+), 1 deletion(-) create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/mangabaka/MangaBaka.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/mangabaka/MangaBakaApi.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/mangabaka/MangaBakaInterceptor.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/mangabaka/MangaBakaUtils.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/mangabaka/dto/MangaBakaItem.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/mangabaka/dto/MangaBakaListEntry.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/mangabaka/dto/MangaBakaOAuth.kt create mode 100644 app/src/main/java/eu/kanade/tachiyomi/data/track/mangabaka/dto/MangaBakaUserProfile.kt create mode 100644 app/src/main/res/drawable-nodpi/brand_mangabaka.webp diff --git a/CHANGELOG.md b/CHANGELOG.md index 2748503f5..7de9b58f8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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)) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 058986cf8..a36b86e37 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -193,6 +193,7 @@ + diff --git a/app/src/main/java/eu/kanade/domain/track/service/TrackPreferences.kt b/app/src/main/java/eu/kanade/domain/track/service/TrackPreferences.kt index e31fb0e68..610f0f1a8 100644 --- a/app/src/main/java/eu/kanade/domain/track/service/TrackPreferences.kt +++ b/app/src/main/java/eu/kanade/domain/track/service/TrackPreferences.kt @@ -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 = preferenceStore.getString("anilist_score_type", Anilist.POINT_10) + val mangabakaScoreType: Preference = preferenceStore.getString("mangabaka_score_type", MangaBaka.STEP_1) + val autoUpdateTrack: Preference = preferenceStore.getBoolean("pref_auto_update_manga_sync_key", true) val autoUpdateTrackOnMarkRead: Preference = preferenceStore.getEnum( diff --git a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsTrackingScreen.kt b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsTrackingScreen.kt index 3068e06c5..c141a74c0 100644 --- a/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsTrackingScreen.kt +++ b/app/src/main/java/eu/kanade/presentation/more/settings/screen/SettingsTrackingScreen.kt @@ -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) }, diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/TrackerManager.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/TrackerManager.kt index 1935a6b1e..6db63d3f0 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/TrackerManager.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/TrackerManager.kt @@ -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 } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/mangabaka/MangaBaka.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangabaka/MangaBaka.kt new file mode 100644 index 000000000..5c35f76aa --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangabaka/MangaBaka.kt @@ -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 { + 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 { + 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 { + 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:" + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/mangabaka/MangaBakaApi.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangabaka/MangaBakaApi.kt new file mode 100644 index 000000000..e84439339 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangabaka/MangaBakaApi.kt @@ -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() + .data + + val additionalData = authClient.newCall(GET("$API_BASE_URL/v1/series/${track.remote_id}")) + .awaitSuccess() + .parseAs() + .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 { + 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() + .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() + .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() + .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 + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/mangabaka/MangaBakaInterceptor.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangabaka/MangaBakaInterceptor.kt new file mode 100644 index 000000000..afb760c06 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangabaka/MangaBakaInterceptor.kt @@ -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) + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/mangabaka/MangaBakaUtils.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangabaka/MangaBakaUtils.kt new file mode 100644 index 000000000..74b7df018 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangabaka/MangaBakaUtils.kt @@ -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") +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/mangabaka/dto/MangaBakaItem.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangabaka/dto/MangaBakaItem.kt new file mode 100644 index 000000000..81d4b061d --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangabaka/dto/MangaBakaItem.kt @@ -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, +) + +@Serializable +data class MangaBakaItem( + val id: Long, + val cover: MangaBakaCover, + val authors: List?, + val artists: List?, + val description: String?, + val published: MangaBakaPublishData, + val status: String, + val type: String, + val rating: Double?, + val titles: List?, +) { + 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, + val title: String, + @SerialName("is_primary") + val isPrimary: Boolean, +) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/mangabaka/dto/MangaBakaListEntry.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangabaka/dto/MangaBakaListEntry.kt new file mode 100644 index 000000000..416c2fb63 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangabaka/dto/MangaBakaListEntry.kt @@ -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") + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/mangabaka/dto/MangaBakaOAuth.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangabaka/dto/MangaBakaOAuth.kt new file mode 100644 index 000000000..6ebad3b8a --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangabaka/dto/MangaBakaOAuth.kt @@ -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 +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/mangabaka/dto/MangaBakaUserProfile.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangabaka/dto/MangaBakaUserProfile.kt new file mode 100644 index 000000000..0675ab9b5 --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/mangabaka/dto/MangaBakaUserProfile.kt @@ -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?, +) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/track/TrackLoginActivity.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/track/TrackLoginActivity.kt index 028416b23..f5f057bca 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/track/TrackLoginActivity.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/track/TrackLoginActivity.kt @@ -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) diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/PkceUtil.kt b/app/src/main/java/eu/kanade/tachiyomi/util/PkceUtil.kt index 9699a7e9f..81b9258c7 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/PkceUtil.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/PkceUtil.kt @@ -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, +) diff --git a/app/src/main/res/drawable-nodpi/brand_mangabaka.webp b/app/src/main/res/drawable-nodpi/brand_mangabaka.webp new file mode 100644 index 0000000000000000000000000000000000000000..89e20cfa534114195694321a56f4f191d51e8473 GIT binary patch literal 17572 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 LNl{qj00000(OMWw literal 0 HcmV?d00001 diff --git a/i18n/src/commonMain/moko-resources/base/strings.xml b/i18n/src/commonMain/moko-resources/base/strings.xml index 1d06cb4e4..122781492 100644 --- a/i18n/src/commonMain/moko-resources/base/strings.xml +++ b/i18n/src/commonMain/moko-resources/base/strings.xml @@ -786,6 +786,7 @@ On hold Paused Plan to read + Considering Rereading Reading List Wish List