فهرست منبع

Add Komga as an unattended track service (#5049)

* fix: prevent crash if TrackService.getScoreList() is empty

* disabled track score button if service doesn't support scoring

* first implementation of the Komga tracking
this doesn't work for read lists

* auto track when adding to library

* handle refresh

* 2-way sync of chapters for unattended tracking services

* Update app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackSheet.kt

Co-authored-by: Andreas <[email protected]>

* group strings together

* support for read lists

* sync read chapters on bind

* only mark local chapters as read during 2-way sync (incoming)

* local progress from read chapters will be sent to remote tracker on bind/refresh
this enables syncing after reading offline

* remove unused variable

* refactor the 2-way sync in a util function

* handle auto add to track for unattended services from the browse source screen when long clicking
this will also sync chapters, as it is possible to have read or marked as read chapters from there

* 2-way sync when library update for TRACKING

* refactor

* better handling of what has been read server side

* refactor: extract function

* fix: localLastRead could be -1 when all chapters are read

* refactor to rethrow exception so it can be shown in toast

* extract strings

* replace komga logo

Co-authored-by: Andreas <[email protected]>
Gauthier 3 سال پیش
والد
کامیت
d6b3b0baf7
18فایلهای تغییر یافته به همراه481 افزوده شده و 7 حذف شده
  1. 6 0
      app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateService.kt
  2. 2 0
      app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferenceKeys.kt
  3. 2 0
      app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt
  4. 8 0
      app/src/main/java/eu/kanade/tachiyomi/data/track/NoLoginTrackService.kt
  5. 5 1
      app/src/main/java/eu/kanade/tachiyomi/data/track/TrackManager.kt
  6. 21 0
      app/src/main/java/eu/kanade/tachiyomi/data/track/UnattendedTrackService.kt
  7. 99 0
      app/src/main/java/eu/kanade/tachiyomi/data/track/komga/Komga.kt
  8. 84 0
      app/src/main/java/eu/kanade/tachiyomi/data/track/komga/KomgaApi.kt
  9. 83 0
      app/src/main/java/eu/kanade/tachiyomi/data/track/komga/KomgaModels.kt
  10. 31 0
      app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourcePresenter.kt
  11. 21 0
      app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaController.kt
  12. 10 0
      app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaPresenter.kt
  13. 7 0
      app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackHolder.kt
  14. 36 3
      app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackSheet.kt
  15. 18 3
      app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsTrackingController.kt
  16. 42 0
      app/src/main/java/eu/kanade/tachiyomi/util/chapter/ChapterTrackSync.kt
  17. BIN
      app/src/main/res/drawable-xhdpi/ic_tracker_komga.webp
  18. 6 0
      app/src/main/res/values/strings.xml

+ 6 - 0
app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateService.kt

@@ -21,12 +21,14 @@ import eu.kanade.tachiyomi.data.library.LibraryUpdateService.Companion.start
 import eu.kanade.tachiyomi.data.notification.Notifications
 import eu.kanade.tachiyomi.data.preference.PreferencesHelper
 import eu.kanade.tachiyomi.data.track.TrackManager
+import eu.kanade.tachiyomi.data.track.UnattendedTrackService
 import eu.kanade.tachiyomi.source.SourceManager
 import eu.kanade.tachiyomi.source.model.SManga
 import eu.kanade.tachiyomi.source.model.toSChapter
 import eu.kanade.tachiyomi.source.model.toSManga
 import eu.kanade.tachiyomi.util.chapter.NoChaptersException
 import eu.kanade.tachiyomi.util.chapter.syncChaptersWithSource
+import eu.kanade.tachiyomi.util.chapter.syncChaptersWithTrackServiceTwoWay
 import eu.kanade.tachiyomi.util.prepUpdateCover
 import eu.kanade.tachiyomi.util.shouldDownloadNewChapters
 import eu.kanade.tachiyomi.util.storage.getUriCompat
@@ -416,6 +418,10 @@ class LibraryUpdateService(
                                 try {
                                     val updatedTrack = service.refresh(track)
                                     db.insertTrack(updatedTrack).executeAsBlocking()
+
+                                    if (service is UnattendedTrackService) {
+                                        syncChaptersWithTrackServiceTwoWay(db, db.getChapters(manga).executeAsBlocking(), track, service)
+                                    }
                                 } catch (e: Throwable) {
                                     // Ignore errors and continue
                                     Timber.e(e)

+ 2 - 0
app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferenceKeys.kt

@@ -97,6 +97,8 @@ object PreferenceKeys {
 
     const val autoUpdateTrack = "pref_auto_update_manga_sync_key"
 
+    const val autoAddTrack = "pref_auto_add_track_key"
+
     const val lastUsedSource = "last_catalogue_source"
 
     const val lastUsedCategory = "last_used_category"

+ 2 - 0
app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt

@@ -171,6 +171,8 @@ class PreferencesHelper(val context: Context) {
 
     fun autoUpdateTrack() = prefs.getBoolean(Keys.autoUpdateTrack, true)
 
+    fun autoAddTrack() = prefs.getBoolean(Keys.autoAddTrack, true)
+
     fun lastUsedSource() = flowPrefs.getLong(Keys.lastUsedSource, -1)
 
     fun lastUsedCategory() = flowPrefs.getInt(Keys.lastUsedCategory, 0)

+ 8 - 0
app/src/main/java/eu/kanade/tachiyomi/data/track/NoLoginTrackService.kt

@@ -0,0 +1,8 @@
+package eu.kanade.tachiyomi.data.track
+
+/**
+ * A TrackService that doesn't need explicit login.
+ */
+interface NoLoginTrackService {
+    fun loginNoop()
+}

+ 5 - 1
app/src/main/java/eu/kanade/tachiyomi/data/track/TrackManager.kt

@@ -4,6 +4,7 @@ import android.content.Context
 import eu.kanade.tachiyomi.data.track.anilist.Anilist
 import eu.kanade.tachiyomi.data.track.bangumi.Bangumi
 import eu.kanade.tachiyomi.data.track.kitsu.Kitsu
+import eu.kanade.tachiyomi.data.track.komga.Komga
 import eu.kanade.tachiyomi.data.track.myanimelist.MyAnimeList
 import eu.kanade.tachiyomi.data.track.shikimori.Shikimori
 
@@ -15,6 +16,7 @@ class TrackManager(context: Context) {
         const val KITSU = 3
         const val SHIKIMORI = 4
         const val BANGUMI = 5
+        const val KOMGA = 6
     }
 
     val myAnimeList = MyAnimeList(context, MYANIMELIST)
@@ -27,7 +29,9 @@ class TrackManager(context: Context) {
 
     val bangumi = Bangumi(context, BANGUMI)
 
-    val services = listOf(myAnimeList, aniList, kitsu, shikimori, bangumi)
+    val komga = Komga(context, KOMGA)
+
+    val services = listOf(myAnimeList, aniList, kitsu, shikimori, bangumi, komga)
 
     fun getService(id: Int) = services.find { it.id == id }
 

+ 21 - 0
app/src/main/java/eu/kanade/tachiyomi/data/track/UnattendedTrackService.kt

@@ -0,0 +1,21 @@
+package eu.kanade.tachiyomi.data.track
+
+import eu.kanade.tachiyomi.data.database.models.Manga
+import eu.kanade.tachiyomi.data.track.model.TrackSearch
+import eu.kanade.tachiyomi.source.Source
+
+/**
+ * An Unattended Track Service will never prompt the user to match a manga with the remote.
+ * It is expected that such Track Sercice can only work with specific sources and unique IDs.
+ */
+interface UnattendedTrackService {
+    /**
+     * This TrackService will only work with the sources that are accepted by this filter function.
+     */
+    fun accept(source: Source): Boolean
+
+    /**
+     * match is similar to TrackService.search, but only return zero or one match.
+     */
+    suspend fun match(manga: Manga): TrackSearch?
+}

+ 99 - 0
app/src/main/java/eu/kanade/tachiyomi/data/track/komga/Komga.kt

@@ -0,0 +1,99 @@
+package eu.kanade.tachiyomi.data.track.komga
+
+import android.content.Context
+import android.graphics.Color
+import androidx.annotation.StringRes
+import eu.kanade.tachiyomi.R
+import eu.kanade.tachiyomi.data.database.models.Manga
+import eu.kanade.tachiyomi.data.database.models.Track
+import eu.kanade.tachiyomi.data.track.NoLoginTrackService
+import eu.kanade.tachiyomi.data.track.TrackService
+import eu.kanade.tachiyomi.data.track.UnattendedTrackService
+import eu.kanade.tachiyomi.data.track.model.TrackSearch
+import eu.kanade.tachiyomi.source.Source
+import okhttp3.Dns
+import okhttp3.OkHttpClient
+
+class Komga(private val context: Context, id: Int) : TrackService(id), UnattendedTrackService, NoLoginTrackService {
+
+    companion object {
+        const val UNREAD = 1
+        const val READING = 2
+        const val COMPLETED = 3
+
+        const val ACCEPTED_SOURCE = "eu.kanade.tachiyomi.extension.all.komga.Komga"
+    }
+
+    override val client: OkHttpClient =
+        networkService.client.newBuilder()
+            .dns(Dns.SYSTEM) // don't use DNS over HTTPS as it breaks IP addressing
+            .build()
+
+    val api by lazy { KomgaApi(client) }
+
+    @StringRes
+    override fun nameRes() = R.string.tracker_komga
+
+    override fun getLogo() = R.drawable.ic_tracker_komga
+
+    override fun getLogoColor() = Color.rgb(51, 37, 50)
+
+    override fun getStatusList() = listOf(UNREAD, READING, COMPLETED)
+
+    override fun getStatus(status: Int): String = with(context) {
+        when (status) {
+            UNREAD -> getString(R.string.unread)
+            READING -> getString(R.string.currently_reading)
+            COMPLETED -> getString(R.string.completed)
+            else -> ""
+        }
+    }
+
+    override fun getCompletionStatus(): Int = COMPLETED
+
+    override fun getScoreList(): List<String> = emptyList()
+
+    override fun displayScore(track: Track): String = ""
+
+    override suspend fun add(track: Track): Track {
+        TODO("Not yet implemented: add")
+    }
+
+    override suspend fun update(track: Track): Track {
+        return api.updateProgress(track)
+    }
+
+    override suspend fun bind(track: Track): Track {
+        return track
+    }
+
+    override suspend fun search(query: String): List<TrackSearch> {
+        TODO("Not yet implemented: search")
+    }
+
+    override suspend fun refresh(track: Track): Track {
+        val remoteTrack = api.getTrackSearch(track.tracking_url)!!
+        track.copyPersonalFrom(remoteTrack)
+        track.total_chapters = remoteTrack.total_chapters
+        return track
+    }
+
+    override suspend fun login(username: String, password: String) {
+        saveCredentials("user", "pass")
+    }
+
+    // TrackService.isLogged works by checking that credentials are saved.
+    // By saving dummy, unused credentials, we can activate the tracker simply by login/logout
+    override fun loginNoop() {
+        saveCredentials("user", "pass")
+    }
+
+    override fun accept(source: Source): Boolean = source::class.qualifiedName == ACCEPTED_SOURCE
+
+    override suspend fun match(manga: Manga): TrackSearch? =
+        try {
+            api.getTrackSearch(manga.url)
+        } catch (e: Exception) {
+            null
+        }
+}

+ 84 - 0
app/src/main/java/eu/kanade/tachiyomi/data/track/komga/KomgaApi.kt

@@ -0,0 +1,84 @@
+package eu.kanade.tachiyomi.data.track.komga
+
+import eu.kanade.tachiyomi.data.database.models.Track
+import eu.kanade.tachiyomi.data.track.TrackManager
+import eu.kanade.tachiyomi.data.track.model.TrackSearch
+import eu.kanade.tachiyomi.network.GET
+import eu.kanade.tachiyomi.network.await
+import eu.kanade.tachiyomi.network.parseAs
+import eu.kanade.tachiyomi.util.lang.withIOContext
+import kotlinx.serialization.encodeToString
+import kotlinx.serialization.json.Json
+import okhttp3.MediaType.Companion.toMediaType
+import okhttp3.OkHttpClient
+import okhttp3.Request
+import okhttp3.RequestBody.Companion.toRequestBody
+import timber.log.Timber
+import uy.kohesive.injekt.injectLazy
+
+const val READLIST_API = "/api/v1/readlists"
+
+class KomgaApi(private val client: OkHttpClient) {
+
+    private val json: Json by injectLazy()
+
+    suspend fun getTrackSearch(url: String): TrackSearch =
+        withIOContext {
+            try {
+                val track = if (url.contains(READLIST_API)) {
+                    client.newCall(GET(url))
+                        .await()
+                        .parseAs<ReadListDto>()
+                        .toTrack()
+                } else {
+                    client.newCall(GET(url))
+                        .await()
+                        .parseAs<SeriesDto>()
+                        .toTrack()
+                }
+
+                val progress = client
+                    .newCall(GET("$url/read-progress/tachiyomi"))
+                    .await()
+                    .parseAs<ReadProgressDto>()
+
+                track.apply {
+                    cover_url = "$url/thumbnail"
+                    tracking_url = url
+                    total_chapters = progress.booksCount
+                    status = when (progress.booksCount) {
+                        progress.booksUnreadCount -> Komga.UNREAD
+                        progress.booksReadCount -> Komga.COMPLETED
+                        else -> Komga.READING
+                    }
+                    last_chapter_read = progress.lastReadContinuousIndex
+                }
+            } catch (e: Exception) {
+                Timber.w(e, "Could not get item: $url")
+                throw e
+            }
+        }
+
+    suspend fun updateProgress(track: Track): Track {
+        val progress = ReadProgressUpdateDto(track.last_chapter_read)
+        val payload = json.encodeToString(progress)
+        client.newCall(
+            Request.Builder()
+                .url("${track.tracking_url}/read-progress/tachiyomi")
+                .put(payload.toRequestBody("application/json".toMediaType()))
+                .build()
+        )
+            .await()
+        return getTrackSearch(track.tracking_url)
+    }
+
+    private fun SeriesDto.toTrack(): TrackSearch = TrackSearch.create(TrackManager.KOMGA).also {
+        it.title = metadata.title
+        it.summary = metadata.summary
+        it.publishing_status = metadata.status
+    }
+
+    private fun ReadListDto.toTrack(): TrackSearch = TrackSearch.create(TrackManager.KOMGA).also {
+        it.title = name
+    }
+}

+ 83 - 0
app/src/main/java/eu/kanade/tachiyomi/data/track/komga/KomgaModels.kt

@@ -0,0 +1,83 @@
+package eu.kanade.tachiyomi.data.track.komga
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class SeriesDto(
+    val id: String,
+    val libraryId: String,
+    val name: String,
+    val created: String?,
+    val lastModified: String?,
+    val fileLastModified: String,
+    val booksCount: Int,
+    val booksReadCount: Int,
+    val booksUnreadCount: Int,
+    val booksInProgressCount: Int,
+    val metadata: SeriesMetadataDto,
+    val booksMetadata: BookMetadataAggregationDto
+)
+
+@Serializable
+data class SeriesMetadataDto(
+    val status: String,
+    val created: String?,
+    val lastModified: String?,
+    val title: String,
+    val titleSort: String,
+    val summary: String,
+    val summaryLock: Boolean,
+    val readingDirection: String,
+    val readingDirectionLock: Boolean,
+    val publisher: String,
+    val publisherLock: Boolean,
+    val ageRating: Int?,
+    val ageRatingLock: Boolean,
+    val language: String,
+    val languageLock: Boolean,
+    val genres: Set<String>,
+    val genresLock: Boolean,
+    val tags: Set<String>,
+    val tagsLock: Boolean
+)
+
+@Serializable
+data class BookMetadataAggregationDto(
+    val authors: List<AuthorDto> = emptyList(),
+    val releaseDate: String?,
+    val summary: String,
+    val summaryNumber: String,
+
+    val created: String,
+    val lastModified: String
+)
+
+@Serializable
+data class AuthorDto(
+    val name: String,
+    val role: String
+)
+
+@Serializable
+data class ReadProgressUpdateDto(
+    val lastBookRead: Int,
+)
+
+@Serializable
+data class ReadListDto(
+    val id: String,
+    val name: String,
+    val bookIds: List<String>,
+    val createdDate: String,
+    val lastModifiedDate: String,
+    val filtered: Boolean
+)
+
+@Serializable
+data class ReadProgressDto(
+    val booksCount: Int,
+    val booksReadCount: Int,
+    val booksUnreadCount: Int,
+    val booksInProgressCount: Int,
+    val lastReadContinuousIndex: Int,
+)

+ 31 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/browse/source/browse/BrowseSourcePresenter.kt

@@ -9,6 +9,9 @@ import eu.kanade.tachiyomi.data.database.models.Manga
 import eu.kanade.tachiyomi.data.database.models.MangaCategory
 import eu.kanade.tachiyomi.data.database.models.toMangaInfo
 import eu.kanade.tachiyomi.data.preference.PreferencesHelper
+import eu.kanade.tachiyomi.data.track.TrackManager
+import eu.kanade.tachiyomi.data.track.TrackService
+import eu.kanade.tachiyomi.data.track.UnattendedTrackService
 import eu.kanade.tachiyomi.source.CatalogueSource
 import eu.kanade.tachiyomi.source.SourceManager
 import eu.kanade.tachiyomi.source.model.Filter
@@ -30,6 +33,7 @@ import eu.kanade.tachiyomi.ui.browse.source.filter.TextSectionItem
 import eu.kanade.tachiyomi.ui.browse.source.filter.TriStateItem
 import eu.kanade.tachiyomi.ui.browse.source.filter.TriStateSectionItem
 import eu.kanade.tachiyomi.util.chapter.ChapterSettingsHelper
+import eu.kanade.tachiyomi.util.chapter.syncChaptersWithTrackServiceTwoWay
 import eu.kanade.tachiyomi.util.lang.launchIO
 import eu.kanade.tachiyomi.util.lang.withUIContext
 import eu.kanade.tachiyomi.util.removeCovers
@@ -102,6 +106,8 @@ open class BrowseSourcePresenter(
      */
     private var pageSubscription: Subscription? = null
 
+    private val loggedServices by lazy { Injekt.get<TrackManager>().services.filter { it.isLogged } }
+
     init {
         query = searchQuery ?: ""
     }
@@ -260,11 +266,36 @@ open class BrowseSourcePresenter(
             manga.removeCovers(coverCache)
         } else {
             ChapterSettingsHelper.applySettingDefaults(manga)
+
+            if (prefs.autoAddTrack()) {
+                autoAddTrack(manga)
+            }
         }
 
         db.insertManga(manga).executeAsBlocking()
     }
 
+    private fun autoAddTrack(manga: Manga) {
+        loggedServices
+            .filterIsInstance<UnattendedTrackService>()
+            .filter { it.accept(source) }
+            .forEach { service ->
+                launchIO {
+                    try {
+                        service.match(manga)?.let { track ->
+                            track.manga_id = manga.id!!
+                            (service as TrackService).bind(track)
+                            db.insertTrack(track).executeAsBlocking()
+
+                            syncChaptersWithTrackServiceTwoWay(db, db.getChapters(manga).executeAsBlocking(), track, service as TrackService)
+                        }
+                    } catch (e: Exception) {
+                        Timber.w(e, "Could not match manga: ${manga.title} with service $service")
+                    }
+                }
+            }
+    }
+
     /**
      * Set the filter states for the current source.
      *

+ 21 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaController.kt

@@ -37,6 +37,8 @@ import eu.kanade.tachiyomi.data.database.models.Manga
 import eu.kanade.tachiyomi.data.download.DownloadService
 import eu.kanade.tachiyomi.data.download.model.Download
 import eu.kanade.tachiyomi.data.preference.PreferencesHelper
+import eu.kanade.tachiyomi.data.track.TrackService
+import eu.kanade.tachiyomi.data.track.UnattendedTrackService
 import eu.kanade.tachiyomi.data.track.model.TrackSearch
 import eu.kanade.tachiyomi.databinding.MangaControllerBinding
 import eu.kanade.tachiyomi.source.LocalSource
@@ -72,6 +74,7 @@ import eu.kanade.tachiyomi.ui.recent.updates.UpdatesController
 import eu.kanade.tachiyomi.ui.webview.WebViewActivity
 import eu.kanade.tachiyomi.util.chapter.NoChaptersException
 import eu.kanade.tachiyomi.util.hasCustomCover
+import eu.kanade.tachiyomi.util.lang.launchIO
 import eu.kanade.tachiyomi.util.lang.launchUI
 import eu.kanade.tachiyomi.util.system.getResourceColor
 import eu.kanade.tachiyomi.util.system.toast
@@ -507,6 +510,24 @@ class MangaController :
                     .showDialog(router)
             }
         }
+
+        if (source != null && preferences.autoAddTrack()) {
+            presenter.trackList
+                .map { it.service }
+                .filterIsInstance<UnattendedTrackService>()
+                .filter { it.accept(source!!) }
+                .forEach { service ->
+                    launchIO {
+                        try {
+                            service.match(manga)?.let { track ->
+                                presenter.registerTracking(track, service as TrackService)
+                            }
+                        } catch (e: Exception) {
+                            Timber.w(e, "Could not match manga: ${manga.title} with service $service")
+                        }
+                    }
+                }
+        }
     }
 
     /**

+ 10 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaPresenter.kt

@@ -17,6 +17,7 @@ import eu.kanade.tachiyomi.data.download.model.Download
 import eu.kanade.tachiyomi.data.preference.PreferencesHelper
 import eu.kanade.tachiyomi.data.track.TrackManager
 import eu.kanade.tachiyomi.data.track.TrackService
+import eu.kanade.tachiyomi.data.track.UnattendedTrackService
 import eu.kanade.tachiyomi.source.LocalSource
 import eu.kanade.tachiyomi.source.Source
 import eu.kanade.tachiyomi.source.model.toSChapter
@@ -26,6 +27,7 @@ import eu.kanade.tachiyomi.ui.manga.chapter.ChapterItem
 import eu.kanade.tachiyomi.ui.manga.track.TrackItem
 import eu.kanade.tachiyomi.util.chapter.ChapterSettingsHelper
 import eu.kanade.tachiyomi.util.chapter.syncChaptersWithSource
+import eu.kanade.tachiyomi.util.chapter.syncChaptersWithTrackServiceTwoWay
 import eu.kanade.tachiyomi.util.isLocal
 import eu.kanade.tachiyomi.util.lang.launchIO
 import eu.kanade.tachiyomi.util.lang.withUIContext
@@ -709,6 +711,10 @@ class MangaPresenter(
                             async {
                                 val track = it.service.refresh(it.track!!)
                                 db.insertTrack(track).executeAsBlocking()
+
+                                if (it.service is UnattendedTrackService) {
+                                    syncChaptersWithTrackServiceTwoWay(db, chapters, track, it.service)
+                                }
                             }
                         }
                         .awaitAll()
@@ -740,6 +746,10 @@ class MangaPresenter(
                 try {
                     service.bind(item)
                     db.insertTrack(item).executeAsBlocking()
+
+                    if (service is UnattendedTrackService) {
+                        syncChaptersWithTrackServiceTwoWay(db, chapters, item, service)
+                    }
                 } catch (e: Throwable) {
                     withUIContext { view?.applicationContext?.toast(e.message) }
                 }

+ 7 - 0
app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackHolder.kt

@@ -3,6 +3,7 @@ package eu.kanade.tachiyomi.ui.manga.track
 import android.annotation.SuppressLint
 import androidx.core.view.isVisible
 import androidx.recyclerview.widget.RecyclerView
+import eu.kanade.tachiyomi.R.string
 import eu.kanade.tachiyomi.data.preference.PreferencesHelper
 import eu.kanade.tachiyomi.databinding.TrackItemBinding
 import uy.kohesive.injekt.injectLazy
@@ -49,6 +50,12 @@ class TrackHolder(private val binding: TrackItemBinding, adapter: TrackAdapter)
                 if (track.total_chapters > 0) track.total_chapters else "-"
             binding.trackStatus.text = item.service.getStatus(track.status)
             binding.trackScore.text = if (track.score == 0f) "-" else item.service.displayScore(track)
+            if (item.service.getScoreList().isEmpty()) {
+                with(binding.trackScore) {
+                    text = context.getString(string.score_unsupported)
+                    isEnabled = false
+                }
+            }
 
             if (item.service.supportsReadingDates) {
                 binding.trackStartDate.text =

+ 36 - 3
app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackSheet.kt

@@ -5,16 +5,25 @@ import android.view.LayoutInflater
 import android.view.View
 import androidx.recyclerview.widget.LinearLayoutManager
 import com.google.android.material.bottomsheet.BottomSheetBehavior
+import eu.kanade.tachiyomi.R.string
 import eu.kanade.tachiyomi.data.database.models.Manga
+import eu.kanade.tachiyomi.data.track.UnattendedTrackService
 import eu.kanade.tachiyomi.databinding.TrackControllerBinding
+import eu.kanade.tachiyomi.source.SourceManager
 import eu.kanade.tachiyomi.ui.base.controller.openInBrowser
 import eu.kanade.tachiyomi.ui.manga.MangaController
+import eu.kanade.tachiyomi.util.lang.launchIO
+import eu.kanade.tachiyomi.util.lang.withUIContext
 import eu.kanade.tachiyomi.util.system.copyToClipboard
+import eu.kanade.tachiyomi.util.system.toast
 import eu.kanade.tachiyomi.widget.sheet.BaseBottomSheetDialog
+import uy.kohesive.injekt.Injekt
+import uy.kohesive.injekt.api.get
 
 class TrackSheet(
     val controller: MangaController,
-    val manga: Manga
+    val manga: Manga,
+    private val sourceManager: SourceManager = Injekt.get()
 ) : BaseBottomSheetDialog(controller.activity!!),
     TrackAdapter.OnClickListener,
     SetTrackStatusDialog.Listener,
@@ -69,7 +78,31 @@ class TrackSheet(
 
     override fun onSetClick(position: Int) {
         val item = adapter.getItem(position) ?: return
-        TrackSearchDialog(controller, item.service).showDialog(controller.router, TAG_SEARCH_CONTROLLER)
+
+        if (item.service is UnattendedTrackService) {
+            if (item.track != null) {
+                controller.presenter.unregisterTracking(item.service)
+                return
+            }
+
+            if (!item.service.accept(sourceManager.getOrStub(manga.source))) {
+                controller.presenter.view?.applicationContext?.toast(string.source_unsupported)
+                return
+            }
+
+            launchIO {
+                try {
+                    item.service.match(manga)?.let { track ->
+                        controller.presenter.registerTracking(track, item.service)
+                    }
+                        ?: withUIContext { controller.presenter.view?.applicationContext?.toast(string.error_no_match) }
+                } catch (e: Exception) {
+                    withUIContext { controller.presenter.view?.applicationContext?.toast(string.error_no_match) }
+                }
+            }
+        } else {
+            TrackSearchDialog(controller, item.service).showDialog(controller.router, TAG_SEARCH_CONTROLLER)
+        }
     }
 
     override fun onTitleLongClick(position: Int) {
@@ -94,7 +127,7 @@ class TrackSheet(
 
     override fun onScoreClick(position: Int) {
         val item = adapter.getItem(position) ?: return
-        if (item.track == null) return
+        if (item.track == null || item.service.getScoreList().isEmpty()) return
 
         SetTrackScoreDialog(controller, this, item).showDialog(controller.router)
     }

+ 18 - 3
app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsTrackingController.kt

@@ -3,6 +3,7 @@ package eu.kanade.tachiyomi.ui.setting
 import android.app.Activity
 import androidx.preference.PreferenceScreen
 import eu.kanade.tachiyomi.R
+import eu.kanade.tachiyomi.data.track.NoLoginTrackService
 import eu.kanade.tachiyomi.data.track.TrackManager
 import eu.kanade.tachiyomi.data.track.TrackService
 import eu.kanade.tachiyomi.data.track.anilist.AnilistApi
@@ -38,6 +39,11 @@ class SettingsTrackingController :
             titleRes = R.string.pref_auto_update_manga_sync
             defaultValue = true
         }
+        switchPreference {
+            key = Keys.autoAddTrack
+            titleRes = R.string.pref_auto_add_track
+            defaultValue = true
+        }
         preferenceCategory {
             titleRes = R.string.services
 
@@ -58,6 +64,10 @@ class SettingsTrackingController :
             trackPreference(trackManager.bangumi) {
                 activity?.openInBrowser(BangumiApi.authUrl(), trackManager.bangumi.getLogoColor())
             }
+            trackPreference(trackManager.komga) {
+                trackManager.komga.loginNoop()
+                updatePreference(trackManager.komga.id)
+            }
         }
         preferenceCategory {
             infoPreference(R.string.tracking_info)
@@ -76,9 +86,14 @@ class SettingsTrackingController :
             {
                 onClick {
                     if (service.isLogged) {
-                        val dialog = TrackLogoutDialog(service)
-                        dialog.targetController = this@SettingsTrackingController
-                        dialog.showDialog(router)
+                        if (service is NoLoginTrackService) {
+                            service.logout()
+                            updatePreference(service.id)
+                        } else {
+                            val dialog = TrackLogoutDialog(service)
+                            dialog.targetController = this@SettingsTrackingController
+                            dialog.showDialog(router)
+                        }
                     } else {
                         login()
                     }

+ 42 - 0
app/src/main/java/eu/kanade/tachiyomi/util/chapter/ChapterTrackSync.kt

@@ -0,0 +1,42 @@
+package eu.kanade.tachiyomi.util.chapter
+
+import eu.kanade.tachiyomi.data.database.DatabaseHelper
+import eu.kanade.tachiyomi.data.database.models.Chapter
+import eu.kanade.tachiyomi.data.database.models.Track
+import eu.kanade.tachiyomi.data.track.TrackService
+import eu.kanade.tachiyomi.util.lang.launchIO
+import timber.log.Timber
+
+/**
+ * Helper method for syncing a remote track with the local chapters, and back
+ *
+ * @param db the database.
+ * @param chapters a list of chapters from the source.
+ * @param remoteTrack the remote Track object.
+ * @param service the tracker service.
+ */
+fun syncChaptersWithTrackServiceTwoWay(db: DatabaseHelper, chapters: List<Chapter>, remoteTrack: Track, service: TrackService) {
+    val sortedChapters = chapters.sortedBy { it.chapter_number }
+    sortedChapters
+        .filterIndexed { index, chapter -> index < remoteTrack.last_chapter_read && !chapter.read }
+        .forEach { it.read = true }
+    db.updateChaptersProgress(sortedChapters).executeAsBlocking()
+
+    val localLastRead = when {
+        sortedChapters.all { it.read } -> sortedChapters.size
+        sortedChapters.any { !it.read } -> sortedChapters.indexOfFirst { !it.read }
+        else -> 0
+    }
+
+    // update remote
+    remoteTrack.last_chapter_read = localLastRead
+
+    launchIO {
+        try {
+            service.update(remoteTrack)
+            db.insertTrack(remoteTrack).executeAsBlocking()
+        } catch (e: Throwable) {
+            Timber.w(e)
+        }
+    }
+}

BIN
app/src/main/res/drawable-xhdpi/ic_tracker_komga.webp


+ 6 - 0
app/src/main/res/values/strings.xml

@@ -376,6 +376,7 @@
 
       <!-- Tracking section -->
     <string name="pref_auto_update_manga_sync">Update chapter progress after reading</string>
+    <string name="pref_auto_add_track">Track silently when adding manga to library</string>
     <string name="services">Services</string>
     <string name="tracking_info">One-way sync to update the chapter progress in tracking services. Set up tracking for individual manga entries from their tracking button.</string>
 
@@ -581,6 +582,7 @@
     <string name="tracker_anilist" translatable="false">AniList</string>
     <string name="tracker_myanimelist" translatable="false">MyAnimeList</string>
     <string name="tracker_kitsu" translatable="false">Kitsu</string>
+    <string name="tracker_komga" translatable="false">Komga</string>
     <string name="tracker_bangumi" translatable="false">Bangumi</string>
     <string name="tracker_shikimori" translatable="false">Shikimori</string>
     <string name="manga_tracking_tab">Tracking</string>
@@ -589,6 +591,7 @@
         <item quantity="other">%d trackers</item>
     </plurals>
     <string name="add_tracking">Add tracking</string>
+    <string name="unread">Unread</string>
     <string name="reading">Reading</string>
     <string name="currently_reading">Currently reading</string>
     <string name="completed">Completed</string>
@@ -610,6 +613,9 @@
     <string name="error_invalid_date_supplied">Invalid date supplied</string>
     <string name="myanimelist_creds_missing">MAL login credentials not found</string>
     <string name="myanimelist_relogin">Please login to MAL again</string>
+    <string name="score_unsupported">Not supported</string>
+    <string name="source_unsupported">Source is not supported</string>
+    <string name="error_no_match">No match found</string>
 
     <!-- Category activity -->
     <string name="error_category_exists">A category with this name already exists!</string>