feat(MangaDex): use tracker links to associate mangas automatically with trackers (#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
This commit is contained in:
Tim Schneeberger 2025-03-02 17:37:50 +01:00 committed by GitHub
parent 8d062cecfd
commit 217503eab0
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
15 changed files with 347 additions and 77 deletions

View File

@ -42,4 +42,11 @@ class TrackPreferences(
"pref_auto_update_manga_on_mark_read",
AutoTrackState.ALWAYS,
)
// SY -->
fun resolveUsingSourceMetadata() = preferenceStore.getBoolean(
"pref_resolve_using_source_metadata_key",
true,
)
// SY <--
}

View File

@ -59,6 +59,7 @@ import tachiyomi.core.common.util.lang.launchIO
import tachiyomi.core.common.util.lang.withUIContext
import tachiyomi.domain.source.service.SourceManager
import tachiyomi.i18n.MR
import tachiyomi.i18n.sy.SYMR
import tachiyomi.presentation.core.components.material.padding
import tachiyomi.presentation.core.i18n.stringResource
import uy.kohesive.injekt.Injekt
@ -135,6 +136,13 @@ object SettingsTrackingScreen : SearchableSettings {
.associateWith { stringResource(it.titleRes) }
.toPersistentMap(),
),
// SY -->
Preference.PreferenceItem.SwitchPreference(
pref = 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
@ -125,6 +126,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

@ -85,4 +85,8 @@ interface Tracker {
suspend fun setRemoteFinishDate(track: Track, epochMillis: Long)
suspend fun getMangaMetadata(track: DomainTrack): TrackMangaMetadata?
// SY -->
suspend fun searchById(id: String): TrackSearch?
// SY <--
}

View File

@ -237,6 +237,12 @@ class Anilist(id: Long) : BaseTracker(id, "AniList"), DeletableTracker {
return api.getMangaMetadata(track)
}
// SY -->
override suspend fun searchById(id: String): TrackSearch {
return api.searchById(id)
}
// 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
@ -356,6 +357,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

@ -133,6 +133,29 @@ 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 base36Id.toLong(36).let { longId ->
api.getSeries(longId).toTrackSearch(this.id)
}
}
// 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,7 @@ import kotlinx.serialization.json.putJsonObject
import okhttp3.MediaType.Companion.toMediaType
import okhttp3.OkHttpClient
import okhttp3.RequestBody.Companion.toRequestBody
import okhttp3.Response
import uy.kohesive.injekt.injectLazy
import tachiyomi.domain.track.model.Track as DomainTrack
@ -190,14 +192,35 @@ 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? =
client.newBuilder()
.followRedirects(false)
.build()
.newCall(GET("https://www.mangaupdates.com/series.html?id=$legacyId"))
.await()
.takeIf(Response::isRedirect)
?.header("Location")
?.let {
// Extract the new id from the redirected URL
Regex("""/series/(\w+)(/([\w-]+)?)?/?${'$'}""")
.find(it)
?.groups?.get(1)
?.value
}
// SY <--
companion object {
private const val BASE_URL = "https://api.mangaupdates.com"

View File

@ -11,7 +11,6 @@ import eu.kanade.tachiyomi.data.track.model.TrackSearch
import eu.kanade.tachiyomi.data.track.myanimelist.dto.MALOAuth
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
import tachiyomi.i18n.MR
import uy.kohesive.injekt.injectLazy
@ -161,6 +160,12 @@ class MyAnimeList(id: Long) : BaseTracker(id, "MyAnimeList"), DeletableTracker {
return api.getMangaMetadata(track)
}
// SY -->
override suspend fun searchById(id: String): TrackSearch {
return api.getMangaDetails(id.toInt())
}
// 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
@ -70,6 +79,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
@ -102,77 +112,94 @@ 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) },
)
},
onCopyLink = { context.copyTrackerLink(it) },
)
}
}
}
/**
@ -196,6 +223,10 @@ 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 <-- */
) : StateScreenModel<Model.State>(State()) {
init {
@ -225,6 +256,79 @@ 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 sourceManager = Injekt.get<SourceManager>()
val getFlatMetadataById = Injekt.get<GetFlatMetadataById>()
val metadataSource = sourceManager.get(sourceId)
?.getMainSource<MetadataSource<*, *>>() ?: return null
return getFlatMetadataById.await(mangaId)?.run {
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 search manga on tracker by id" }
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>()
@ -260,6 +364,9 @@ data class TrackInfoDialogHomeScreen(
@Immutable
data class State(
val trackItems: List<TrackItem> = emptyList(),
// SY -->
val isLoading: Boolean = false,
// SY <--
)
}
}

View File

@ -125,4 +125,6 @@ data class DummyTracker(
): eu.kanade.tachiyomi.data.track.model.TrackMangaMetadata = eu.kanade.tachiyomi.data.track.model.TrackMangaMetadata(
0, "test", "test", "test", "test", "test",
)
override suspend fun searchById(id: String) = null
}

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?
}