feat(MangaDex): use tracker links to associate mangas automatically with trackers (#991)

* feat(MangaDex): use tracker links to associate mangas automatically with trackers (jobobby04/TachiyomiSY#1387)

* feat: add searchById support to trackers (MAL, AniList, MangaUpdates only)

* feat: add new preference to toggle auto selection of tracker items using source metadata if available

* feat: add new preference to toggle auto selection of tracker items using source metadata if available

* feat: add automatic title selection using source metadata to TrackInfoDialog.kt

* style: apply spotless

* refactor: remove hardcoded MangaDexSearchMetadata cast and introduce common interface

(cherry picked from commit 217503eab0e2fe551943e3b702cc6df4aaae8ebf)

* Handle null when searchId failed

* Wrap the await() call in a use block to ensure the OkHttp Response is properly closed after use

* Simplify Regex

* Injection via construction

* Wrap the blocking HTTP call in an IO dispatcher context

---------

Co-authored-by: Tim Schneeberger <tim.schneeberger@outlook.de>
This commit is contained in:
Cuong-Tran 2025-06-09 18:43:05 +07:00 committed by GitHub
parent 2661122d9a
commit cd3dc79583
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
15 changed files with 375 additions and 77 deletions

View file

@ -43,6 +43,13 @@ class TrackPreferences(
AutoTrackState.ALWAYS,
)
// SY -->
fun resolveUsingSourceMetadata() = preferenceStore.getBoolean(
"pref_resolve_using_source_metadata_key",
true,
)
// SY <--
// KMK -->
fun autoSyncProgressFromTrackers() = preferenceStore.getBoolean("pref_auto_sync_progress_from_trackers_key", true)
// KMK <--

View file

@ -63,6 +63,7 @@ import tachiyomi.core.common.util.lang.withUIContext
import tachiyomi.domain.source.service.SourceManager
import tachiyomi.i18n.MR
import tachiyomi.i18n.kmk.KMR
import tachiyomi.i18n.sy.SYMR
import tachiyomi.presentation.core.components.material.padding
import tachiyomi.presentation.core.i18n.stringResource
import uy.kohesive.injekt.Injekt
@ -146,6 +147,13 @@ object SettingsTrackingScreen : SearchableSettings {
title = stringResource(KMR.strings.pref_auto_sync_progress_from_trackers),
),
// KMK <--
// SY -->
Preference.PreferenceItem.SwitchPreference(
preference = trackPreferences.resolveUsingSourceMetadata(),
title = stringResource(SYMR.strings.pref_tracker_resolve_using_source_metadata),
subtitle = stringResource(SYMR.strings.pref_tracker_resolve_using_source_metadata_summary),
),
// SY <--
Preference.PreferenceGroup(
title = stringResource(MR.strings.services),
preferenceItems = persistentListOf(

View file

@ -7,6 +7,7 @@ import eu.kanade.domain.track.model.toDomainTrack
import eu.kanade.domain.track.service.TrackPreferences
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.model.TrackMangaMetadata
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.network.NetworkHelper
import eu.kanade.tachiyomi.util.system.toast
import kotlinx.coroutines.flow.Flow
@ -132,6 +133,12 @@ abstract class BaseTracker(
throw NotImplementedError("Not implemented.")
}
// SY -->
override suspend fun searchById(id: String): TrackSearch? {
throw NotImplementedError("Not implemented.")
}
// SY <--
private suspend fun updateRemote(track: Track): Unit = withIOContext {
try {
update(track)

View file

@ -90,6 +90,8 @@ interface Tracker {
// SY -->
suspend fun getMangaMetadata(track: DomainTrack): TrackMangaMetadata
suspend fun searchById(id: String): TrackSearch?
// SY <--
// KMK -->

View file

@ -10,6 +10,7 @@ import eu.kanade.tachiyomi.data.track.DeletableTracker
import eu.kanade.tachiyomi.data.track.anilist.dto.ALOAuth
import eu.kanade.tachiyomi.data.track.model.TrackMangaMetadata
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import exh.log.xLogW
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
@ -238,6 +239,17 @@ class Anilist(id: Long) : BaseTracker(id, "AniList"), DeletableTracker {
return api.getMangaMetadata(track)
}
// SY -->
override suspend fun searchById(id: String): TrackSearch? {
return try {
api.searchById(id)
} catch (e: Exception) {
xLogW("Error during searchById '$id': ${e.message}", e)
null
}
}
// SY <--
fun saveOAuth(alOAuth: ALOAuth?) {
trackPreferences.trackToken(this).set(json.encodeToString(alOAuth))
}

View file

@ -5,6 +5,7 @@ import androidx.core.net.toUri
import eu.kanade.tachiyomi.data.database.models.Track
import eu.kanade.tachiyomi.data.track.anilist.dto.ALAddMangaResult
import eu.kanade.tachiyomi.data.track.anilist.dto.ALCurrentUserResult
import eu.kanade.tachiyomi.data.track.anilist.dto.ALIdSearchResult
import eu.kanade.tachiyomi.data.track.anilist.dto.ALMangaMetadata
import eu.kanade.tachiyomi.data.track.anilist.dto.ALOAuth
import eu.kanade.tachiyomi.data.track.anilist.dto.ALSearchResult
@ -388,6 +389,56 @@ class AnilistApi(val client: OkHttpClient, interceptor: AnilistInterceptor) {
}
}
// SY -->
suspend fun searchById(id: String): TrackSearch {
return withIOContext {
val query = """
|query (${'$'}mangaId: Int!) {
|Media (id: ${'$'}mangaId) {
|id
|title {
|userPreferred
|}
|coverImage {
|large
|}
|format
|status
|chapters
|description
|startDate {
|year
|month
|day
|}
|averageScore
|}
|}
|
""".trimMargin()
val payload = buildJsonObject {
put("query", query)
putJsonObject("variables") {
put("mangaId", id)
}
}
with(json) {
authClient.newCall(
POST(
API_URL,
body = payload.toString().toRequestBody(jsonMime),
),
)
.awaitSuccess()
.parseAs<ALIdSearchResult>()
.data.media
.toALManga()
.toTrack()
}
}
}
// SY <--
private fun createDate(dateValue: Long): JsonObject {
if (dateValue == 0L) {
return buildJsonObject {

View file

@ -18,3 +18,16 @@ data class ALSearchPage(
data class ALSearchMedia(
val media: List<ALSearchItem>,
)
// SY -->
@Serializable
data class ALIdSearchResult(
val data: ALIdSearchMedia,
)
@Serializable
data class ALIdSearchMedia(
@SerialName("Media")
val media: ALSearchItem,
)
// SY <--

View file

@ -13,6 +13,7 @@ import eu.kanade.tachiyomi.data.track.mangaupdates.dto.toTrackSearch
import eu.kanade.tachiyomi.data.track.model.TrackMangaMetadata
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.util.lang.htmlDecode
import exh.log.xLogW
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import tachiyomi.i18n.MR
@ -135,6 +136,34 @@ class MangaUpdates(id: Long) : BaseTracker(id, "MangaUpdates"), DeletableTracker
}
}
// SY -->
override suspend fun searchById(id: String): TrackSearch? {
/*
* MangaUpdates uses newer base36 IDs (in URLs displayed as an encoded string, internally as a long)
* as well as older sequential numeric IDs, which were phased out to prevent heavy load caused by
* database scraping. Unfortunately, sites like MD sometimes still provides links with the old IDs,
* so we need to convert them.
* Because the API only accepts the newer IDs, we are forced to access the legacy non-API website
* (ex. https://www.mangaupdates.com/series.html?id=15), which is a permanent redirect (HTTP 308) to the new one.
*/
val base36Id = if (id.matches(Regex("""^\d+$"""))) {
api.convertToNewId(id.toInt()) ?: return null
} else {
id
}
return try {
base36Id.toLong(36).let { longId ->
api.getSeries(longId).toTrackSearch(this.id)
}
} catch (e: Exception) {
xLogW("Error during searchById '$id': ${e.message}", e)
null
}
}
// SY <--
fun restoreSession(): String? {
return trackPreferences.trackPassword(this).get().ifBlank { null }
}

View file

@ -13,6 +13,7 @@ import eu.kanade.tachiyomi.network.DELETE
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.PUT
import eu.kanade.tachiyomi.network.await
import eu.kanade.tachiyomi.network.awaitSuccess
import eu.kanade.tachiyomi.network.parseAs
import kotlinx.serialization.json.Json
@ -25,6 +26,8 @@ import kotlinx.serialization.json.putJsonObject
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import tachiyomi.core.common.util.lang.withIOContext
import uy.kohesive.injekt.injectLazy
import tachiyomi.domain.track.model.Track as DomainTrack
@ -190,14 +193,38 @@ class MangaUpdatesApi(
}
}
suspend fun getSeries(track: DomainTrack): MURecord {
suspend fun getSeries(track: DomainTrack): MURecord =
getSeries(track.remoteId)
// SY -->
suspend fun getSeries(remoteId: Long): MURecord {
return with(json) {
client.newCall(GET("$BASE_URL/v1/series/${track.remoteId}"))
client.newCall(GET("$BASE_URL/v1/series/$remoteId"))
.awaitSuccess()
.parseAs<MURecord>()
}
}
suspend fun convertToNewId(legacyId: Int): String? = withIOContext {
client.newBuilder()
.followRedirects(false)
.build()
.newCall(GET("https://www.mangaupdates.com/series.html?id=$legacyId"))
.await()
.use { response ->
response.takeIf(Response::isRedirect)
?.header("Location")
?.let { location ->
// Extract the new id from the redirected URL
Regex("""/series/(\w+)""")
.find(location)
?.groups?.get(1)
?.value
}
}
}
// SY <--
companion object {
private const val BASE_URL = "https://api.mangaupdates.com"

View file

@ -9,6 +9,7 @@ import eu.kanade.tachiyomi.data.track.DeletableTracker
import eu.kanade.tachiyomi.data.track.model.TrackMangaMetadata
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.data.track.myanimelist.dto.MALOAuth
import exh.log.xLogW
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.serialization.json.Json
@ -160,6 +161,22 @@ class MyAnimeList(id: Long) : BaseTracker(id, "MyAnimeList"), DeletableTracker {
return api.getMangaMetadata(track)
}
// SY -->
override suspend fun searchById(id: String): TrackSearch? {
val searchId = id.toIntOrNull()
?: run {
xLogW("Invalid ID format for searchById: $id")
return null
}
return try {
api.getMangaDetails(searchId)
} catch (e: Exception) {
xLogW("Error during searchById '$id': ${e.message}", e)
null
}
}
// SY <--
fun getIfAuthExpired(): Boolean {
return trackPreferences.trackAuthExpired(this).get()
}

View file

@ -2,17 +2,20 @@ package eu.kanade.tachiyomi.ui.manga.track
import android.app.Application
import android.content.Context
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.systemBars
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Delete
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.FilledTonalButton
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
@ -30,6 +33,8 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import cafe.adriel.voyager.core.model.ScreenModel
import cafe.adriel.voyager.core.model.StateScreenModel
import cafe.adriel.voyager.core.model.rememberScreenModel
@ -40,6 +45,7 @@ import cafe.adriel.voyager.navigator.currentOrThrow
import dev.icerock.moko.resources.StringResource
import eu.kanade.domain.track.interactor.RefreshTracks
import eu.kanade.domain.track.model.toDbTrack
import eu.kanade.domain.track.service.TrackPreferences
import eu.kanade.domain.ui.UiPreferences
import eu.kanade.presentation.track.TrackChapterSelector
import eu.kanade.presentation.track.TrackDateSelector
@ -53,10 +59,13 @@ import eu.kanade.tachiyomi.data.track.EnhancedTracker
import eu.kanade.tachiyomi.data.track.Tracker
import eu.kanade.tachiyomi.data.track.TrackerManager
import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.source.online.MetadataSource
import eu.kanade.tachiyomi.util.lang.convertEpochMillisZone
import eu.kanade.tachiyomi.util.system.copyToClipboard
import eu.kanade.tachiyomi.util.system.openInBrowser
import eu.kanade.tachiyomi.util.system.toast
import exh.metadata.metadata.base.TrackerIdMetadata
import exh.source.getMainSource
import kotlinx.collections.immutable.ImmutableList
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.collectLatest
@ -71,6 +80,7 @@ import tachiyomi.core.common.util.lang.launchNonCancellable
import tachiyomi.core.common.util.lang.withIOContext
import tachiyomi.core.common.util.lang.withUIContext
import tachiyomi.core.common.util.system.logcat
import tachiyomi.domain.manga.interactor.GetFlatMetadataById
import tachiyomi.domain.manga.interactor.GetManga
import tachiyomi.domain.source.service.SourceManager
import tachiyomi.domain.track.interactor.DeleteTrack
@ -103,78 +113,95 @@ data class TrackInfoDialogHomeScreen(
val dateFormat = remember { UiPreferences.dateFormat(Injekt.get<UiPreferences>().dateFormat().get()) }
val state by screenModel.state.collectAsState()
TrackInfoDialogHome(
trackItems = state.trackItems,
dateFormat = dateFormat,
onStatusClick = {
navigator.push(
TrackStatusSelectorScreen(
track = it.track!!,
serviceId = it.tracker.id,
),
)
},
onChapterClick = {
navigator.push(
TrackChapterSelectorScreen(
track = it.track!!,
serviceId = it.tracker.id,
),
)
},
onScoreClick = {
navigator.push(
TrackScoreSelectorScreen(
track = it.track!!,
serviceId = it.tracker.id,
),
)
},
onStartDateEdit = {
navigator.push(
TrackDateSelectorScreen(
track = it.track!!,
serviceId = it.tracker.id,
start = true,
),
)
},
onEndDateEdit = {
navigator.push(
TrackDateSelectorScreen(
track = it.track!!,
serviceId = it.tracker.id,
start = false,
),
)
},
onNewSearch = {
if (it.tracker is EnhancedTracker) {
screenModel.registerEnhancedTracking(it)
} else {
navigator.push(
TrackerSearchScreen(
mangaId = mangaId,
initialQuery = it.track?.title ?: mangaTitle,
currentUrl = it.track?.remoteUrl,
serviceId = it.tracker.id,
),
// SY -->
Column(modifier = Modifier.animateContentSize()) {
if (state.isLoading) {
Column(
modifier = Modifier
.fillMaxWidth()
.padding(32.dp)
.windowInsetsPadding(WindowInsets.systemBars),
verticalArrangement = Arrangement.spacedBy(24.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
CircularProgressIndicator()
Text(
stringResource(MR.strings.loading),
fontSize = 14.sp,
)
}
},
onOpenInBrowser = { openTrackerInBrowser(context, it) },
onRemoved = {
navigator.push(
TrackerRemoveScreen(
mangaId = mangaId,
track = it.track!!,
serviceId = it.tracker.id,
),
}
// SY <--
else {
TrackInfoDialogHome(
trackItems = state.trackItems,
dateFormat = dateFormat,
onStatusClick = {
navigator.push(
TrackStatusSelectorScreen(
track = it.track!!,
serviceId = it.tracker.id,
),
)
},
onChapterClick = {
navigator.push(
TrackChapterSelectorScreen(
track = it.track!!,
serviceId = it.tracker.id,
),
)
},
onScoreClick = {
navigator.push(
TrackScoreSelectorScreen(
track = it.track!!,
serviceId = it.tracker.id,
),
)
},
onStartDateEdit = {
navigator.push(
TrackDateSelectorScreen(
track = it.track!!,
serviceId = it.tracker.id,
start = true,
),
)
},
onEndDateEdit = {
navigator.push(
TrackDateSelectorScreen(
track = it.track!!,
serviceId = it.tracker.id,
start = false,
),
)
},
onNewSearch = {
if (it.tracker is EnhancedTracker) {
screenModel.registerEnhancedTracking(it)
} else {
// SY -->
screenModel.newSearch(navigator, it, mangaTitle)
// SY <--
}
},
onOpenInBrowser = { openTrackerInBrowser(context, it) },
onRemoved = {
navigator.push(
TrackerRemoveScreen(
mangaId = mangaId,
track = it.track!!,
serviceId = it.tracker.id,
),
)
},
onCopyLink = { context.copyTrackerLink(it) },
onTogglePrivate = screenModel::togglePrivate,
)
},
onCopyLink = { context.copyTrackerLink(it) },
onTogglePrivate = screenModel::togglePrivate,
)
}
}
}
/**
@ -198,6 +225,14 @@ data class TrackInfoDialogHomeScreen(
private val mangaId: Long,
private val sourceId: Long,
private val getTracks: GetTracks = Injekt.get(),
// SY -->
private val trackerManager: TrackerManager = Injekt.get(),
private val trackPreferences: TrackPreferences = Injekt.get(),
// SY <--
// KMK -->
val sourceManager: SourceManager = Injekt.get<SourceManager>(),
val getFlatMetadataById: GetFlatMetadataById = Injekt.get<GetFlatMetadataById>(),
// KMK <--
) : StateScreenModel<Model.State>(State()) {
init {
@ -227,6 +262,77 @@ data class TrackInfoDialogHomeScreen(
}
}
// SY -->
fun newSearch(navigator: Navigator, item: TrackItem, mangaTitle: String) {
screenModelScope.launchNonCancellable {
if (trackPreferences.resolveUsingSourceMetadata().get()) {
// Check if the tracker id is contained in the metadata
val result = getTrackerIdFromMetadata(item.tracker.id)
if (result != null) {
mutableState.update { it.copy(isLoading = true) }
// Try to register tracking by id
val success = registerTrackingById(item.tracker.id, result)
mutableState.update { it.copy(isLoading = false) }
if (success) {
// Return on success
return@launchNonCancellable
}
}
}
// Open search screen
navigator.push(
TrackerSearchScreen(
mangaId = mangaId,
initialQuery = item.track?.title ?: mangaTitle,
currentUrl = item.track?.remoteUrl,
serviceId = item.tracker.id,
),
)
}
}
suspend fun getTrackerIdFromMetadata(trackerId: Long): String? {
try {
val metadataSource = sourceManager.get(sourceId)
?.getMainSource<MetadataSource<*, *>>() ?: return null
return getFlatMetadataById.await(mangaId)?.run {
// Use 'raise' to dynamically obtain the specific metadata type and then attempt to cast
raise(metadataSource.metaClass) as? TrackerIdMetadata
}?.let { metadata ->
when (trackerId) {
trackerManager.aniList.id -> metadata.anilistId
trackerManager.kitsu.id -> metadata.kitsuId
trackerManager.myAnimeList.id -> metadata.myAnimeListId
trackerManager.mangaUpdates.id -> metadata.mangaUpdatesId
else -> null
}
}
} catch (e: Throwable) {
logcat(LogPriority.ERROR, e) { "Failed to get tracker ID from metadata" }
return null
}
}
suspend fun registerTrackingById(trackerId: Long, remoteId: String): Boolean {
trackerManager.get(trackerId)?.let { tracker ->
try {
tracker.searchById(remoteId)?.let { track ->
tracker.register(track, mangaId)
return true
}
} catch (e: Throwable) {
logcat(LogPriority.ERROR, e) { "Failed to register tracking by id" }
}
}
return false
}
// SY <--
private suspend fun refreshTrackers() {
val refreshTracks = Injekt.get<RefreshTracks>()
val context = Injekt.get<Application>()
@ -268,6 +374,9 @@ data class TrackInfoDialogHomeScreen(
@Immutable
data class State(
val trackItems: List<TrackItem> = emptyList(),
// SY -->
val isLoading: Boolean = false,
// SY <--
)
}
}

View file

@ -131,6 +131,8 @@ data class DummyTracker(
override suspend fun getMangaMetadata(
track: Track,
): TrackMangaMetadata = TrackMangaMetadata(0, "test", "test", "test", "test", "test")
override suspend fun searchById(id: String) = null
// SY <--
// KMK -->

View file

@ -176,6 +176,10 @@
<string name="pref_hide_history_button">Show history in the nav</string>
<string name="pref_show_bottom_bar_labels">Always show nav labels</string>
<!-- Tracker settings -->
<string name="pref_tracker_resolve_using_source_metadata">Select entries using source metadata</string>
<string name="pref_tracker_resolve_using_source_metadata_summary">Automatically selects the matching title if the source provides links to trackers. Currently supported by MangaDex</string>
<!-- Library settings -->
<string name="pref_sorting_settings">Sorting Settings</string>
<string name="pref_skip_pre_migration_summary">Use last saved pre-migration preferences and sources to mass migrate</string>

View file

@ -4,13 +4,14 @@ import android.content.Context
import eu.kanade.tachiyomi.source.model.SManga
import eu.kanade.tachiyomi.source.model.copy
import exh.md.utils.MangaDexRelation
import exh.metadata.metadata.base.TrackerIdMetadata
import kotlinx.serialization.Serializable
import tachiyomi.core.common.i18n.stringResource
import tachiyomi.i18n.MR
import tachiyomi.i18n.sy.SYMR
@Serializable
class MangaDexSearchMetadata : RaisedSearchMetadata() {
class MangaDexSearchMetadata : RaisedSearchMetadata(), TrackerIdMetadata {
var mdUuid: String? = null
// var mdUrl: String? = null
@ -31,11 +32,11 @@ class MangaDexSearchMetadata : RaisedSearchMetadata() {
var rating: Float? = null
// var users: String? = null
var anilistId: String? = null
var kitsuId: String? = null
var myAnimeListId: String? = null
var mangaUpdatesId: String? = null
var animePlanetId: String? = null
override var anilistId: String? = null
override var kitsuId: String? = null
override var myAnimeListId: String? = null
override var mangaUpdatesId: String? = null
override var animePlanetId: String? = null
var status: Int? = null

View file

@ -0,0 +1,9 @@
package exh.metadata.metadata.base
interface TrackerIdMetadata {
var anilistId: String?
var kitsuId: String?
var myAnimeListId: String?
var mangaUpdatesId: String?
var animePlanetId: String?
}