diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateService.kt b/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateService.kt index 52074b97c1..d5c856e36e 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateService.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/library/LibraryUpdateService.kt @@ -26,12 +26,14 @@ import eu.kanade.tachiyomi.data.notification.Notifications import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.getOrDefault 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.source.online.HttpSource import eu.kanade.tachiyomi.util.chapter.syncChaptersWithSource +import eu.kanade.tachiyomi.util.chapter.syncChaptersWithTrackServiceTwoWay import eu.kanade.tachiyomi.util.storage.getUriCompat import eu.kanade.tachiyomi.util.manga.MangaShortcutManager import eu.kanade.tachiyomi.util.shouldDownloadNewChapters @@ -496,6 +498,10 @@ class LibraryUpdateService( try { val newTrack = service.refresh(track) db.insertTrack(newTrack).executeAsBlocking() + + if (service is UnattendedTrackService) { + syncChaptersWithTrackServiceTwoWay(db, db.getChapters(manga).executeAsBlocking(), track, service) + } } catch (e: Exception) { Timber.e(e) } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferenceKeys.kt b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferenceKeys.kt index 57dff5b593..f2d3a9fd82 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferenceKeys.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferenceKeys.kt @@ -88,6 +88,8 @@ object PreferenceKeys { const val autoUpdateTrack = "pref_auto_update_manga_sync_key" + const val autoAddTrack = "pref_auto_add_track_key" + const val lastUsedCatalogueSource = "last_catalogue_source" const val lastUsedCategory = "last_used_category" diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt index 2bbb5f6f99..d01a5fab74 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/data/preference/PreferencesHelper.kt @@ -192,6 +192,8 @@ class PreferencesHelper(val context: Context) { fun autoUpdateTrack() = prefs.getBoolean(Keys.autoUpdateTrack, true) + fun autoAddTrack() = prefs.getBoolean(Keys.autoAddTrack, true) + fun lastUsedCatalogueSource() = flowPrefs.getLong(Keys.lastUsedCatalogueSource, -1) fun lastUsedCategory() = rxPrefs.getInteger(Keys.lastUsedCategory, 0) diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/NoLoginTrackService.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/NoLoginTrackService.kt new file mode 100644 index 0000000000..7e5f02fc8f --- /dev/null +++ b/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() +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/TrackManager.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/TrackManager.kt index 173a6ffd6f..05065920c2 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/data/track/TrackManager.kt +++ b/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 } diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/UnattendedTrackService.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/UnattendedTrackService.kt new file mode 100644 index 0000000000..fbbed65eaf --- /dev/null +++ b/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? +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/komga/Komga.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/komga/Komga.kt new file mode 100644 index 0000000000..7ccaf63f3b --- /dev/null +++ b/app/src/main/java/eu/kanade/tachiyomi/data/track/komga/Komga.kt @@ -0,0 +1,116 @@ +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.data.track.updateNewTrackInfo +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.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 isCompletedStatus(index: Int): Boolean = getStatusList()[index] == 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 getGlobalStatus(status: Int): String = with(context) { + when (status) { + UNREAD -> getString(R.string.plan_to_read) + READING -> getString(R.string.reading) + COMPLETED -> getString(R.string.completed) + else -> "" + } + } + + override fun completedStatus(): Int = COMPLETED + + override fun getScoreList(): List = emptyList() + + override fun displayScore(track: Track): String = "" + override suspend fun add(track: Track): Track { + track.status = READING + updateNewTrackInfo(track, UNREAD) + return api.updateProgress(track) + } + + override suspend fun update(track: Track, setToReadStatus: Boolean): Track { + if (setToReadStatus && track.status == UNREAD && track.last_chapter_read != 0) { + track.status = READING + } + return api.updateProgress(track) + } + + override suspend fun bind(track: Track): Track { + return track + } + + override suspend fun search(query: String): List { + 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): Boolean { + saveCredentials("user", "pass") + return true + } + + // 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 + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/komga/KomgaApi.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/komga/KomgaApi.kt new file mode 100644 index 0000000000..4a6abe29fc --- /dev/null +++ b/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.system.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() + .toTrack() + } else { + client.newCall(GET(url)) + .await() + .parseAs() + .toTrack() + } + + val progress = client + .newCall(GET("$url/read-progress/tachiyomi")) + .await() + .parseAs() + + 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 + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/data/track/komga/KomgaModels.kt b/app/src/main/java/eu/kanade/tachiyomi/data/track/komga/KomgaModels.kt new file mode 100644 index 0000000000..d27af7f16d --- /dev/null +++ b/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, + val genresLock: Boolean, + val tags: Set, + val tagsLock: Boolean +) + +@Serializable +data class BookMetadataAggregationDto( + val authors: List = 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, + 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, +) diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaDetailsController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaDetailsController.kt index 35562b7311..1dafae78e0 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaDetailsController.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaDetailsController.kt @@ -1239,7 +1239,12 @@ class MangaDetailsController : updateHeader() showAddedSnack() }, - onMangaMoved = { updateHeader() }, + onMangaMoved = { + updateHeader() + if (presenter.preferences.autoAddTrack()) { + presenter.fetchChapters(andTracking = true) + } + }, onMangaDeleted = { presenter.confirmDeletion() } ) if (snack?.duration == Snackbar.LENGTH_INDEFINITE) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaDetailsPresenter.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaDetailsPresenter.kt index 3894db6a3b..7f2d7e79a6 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaDetailsPresenter.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/MangaDetailsPresenter.kt @@ -30,6 +30,7 @@ import eu.kanade.tachiyomi.data.preference.PreferencesHelper import eu.kanade.tachiyomi.data.preference.getOrDefault 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.SManga @@ -42,6 +43,7 @@ import eu.kanade.tachiyomi.ui.security.SecureActivityDelegate import eu.kanade.tachiyomi.util.chapter.ChapterFilter import eu.kanade.tachiyomi.util.chapter.ChapterUtil import eu.kanade.tachiyomi.util.chapter.syncChaptersWithSource +import eu.kanade.tachiyomi.util.chapter.syncChaptersWithTrackServiceTwoWay import eu.kanade.tachiyomi.util.lang.trimOrNull import eu.kanade.tachiyomi.util.shouldDownloadNewChapters import eu.kanade.tachiyomi.util.storage.DiskUtil @@ -799,6 +801,9 @@ class MangaDetailsPresenter( } if (trackItem != null) { db.insertTrack(trackItem).executeAsBlocking() + if (item.service is UnattendedTrackService) { + syncChaptersWithTrackServiceTwoWay(db, chapters, trackItem, item.service) + } trackItem } else item.track } @@ -847,7 +852,13 @@ class MangaDetailsPresenter( null } withContext(Dispatchers.IO) { - if (binding != null) db.insertTrack(binding).executeAsBlocking() + if (binding != null) { + db.insertTrack(binding).executeAsBlocking() + } + + if (service is UnattendedTrackService) { + syncChaptersWithTrackServiceTwoWay(db, chapters, item, service) + } } fetchTracks() } diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackHolder.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackHolder.kt index 04c60e00f5..f66900fcdb 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackHolder.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackHolder.kt @@ -68,8 +68,15 @@ class TrackHolder(view: View, adapter: TrackAdapter) : BaseViewHolder(view) { val status = item.service.getStatus(track.status) if (status.isEmpty()) binding.trackStatus.setText(R.string.unknown_status) else binding.trackStatus.text = item.service.getStatus(track.status) - binding.trackScore.text = if (track.score == 0f) "-" else item.service.displayScore(track) - binding.trackScore.setCompoundDrawablesWithIntrinsicBounds(0, 0, starIcon(track), 0) + val supportsScoring = item.service.getScoreList().isNotEmpty() + if (supportsScoring) { + binding.trackScore.text = + if (track.score == 0f) "-" else item.service.displayScore(track) + binding.trackScore.setCompoundDrawablesWithIntrinsicBounds(0, 0, starIcon(track), 0) + } + binding.scoreContainer.isVisible = supportsScoring + binding.vertDivider2.isVisible = supportsScoring + binding.dateGroup.isVisible = item.service.supportsReadingDates if (item.service.supportsReadingDates) { binding.trackStartDate.text = diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackingBottomSheet.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackingBottomSheet.kt index 4d79a756cd..9605784173 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackingBottomSheet.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/ui/manga/track/TrackingBottomSheet.kt @@ -11,16 +11,22 @@ import androidx.recyclerview.widget.RecyclerView import com.google.android.material.bottomsheet.BottomSheetBehavior import eu.kanade.tachiyomi.R 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.TrackingBottomSheetBinding +import eu.kanade.tachiyomi.source.SourceManager import eu.kanade.tachiyomi.ui.manga.MangaDetailsController import eu.kanade.tachiyomi.util.system.dpToPx +import eu.kanade.tachiyomi.util.system.launchIO import eu.kanade.tachiyomi.util.system.toast +import eu.kanade.tachiyomi.util.system.withUIContext import eu.kanade.tachiyomi.util.view.RecyclerWindowInsetsListener import eu.kanade.tachiyomi.util.view.checkHeightThen import eu.kanade.tachiyomi.util.view.updateLayoutParams import eu.kanade.tachiyomi.widget.E2EBottomSheetDialog import timber.log.Timber +import uy.kohesive.injekt.api.get +import uy.kohesive.injekt.injectLazy class TrackingBottomSheet(private val controller: MangaDetailsController) : E2EBottomSheetDialog(controller.activity!!), @@ -35,6 +41,7 @@ class TrackingBottomSheet(private val controller: MangaDetailsController) : val presenter = controller.presenter + private val sourceManager: SourceManager by injectLazy() private var adapter: TrackAdapter? = null override fun createBinding(inflater: LayoutInflater) = @@ -140,10 +147,31 @@ class TrackingBottomSheet(private val controller: MangaDetailsController) : return } - TrackSearchDialog(this, item.service, item.track != null).showDialog( - controller.router, - TAG_SEARCH_CONTROLLER - ) + if (item.service is UnattendedTrackService) { + if (item.track != null) { + controller.presenter.removeTracker(item, false) + return + } + + if (!item.service.accept(controller.presenter.source)) { + controller.view?.context?.toast(R.string.source_unsupported) + return + } + + launchIO { + try { + item.service.match(controller.presenter.manga)?.let { track -> + controller.presenter.registerTracking(track, item.service) + } + ?: withUIContext { controller.view?.context?.toast(R.string.no_match_found) } + } catch (e: Exception) { + withUIContext { controller.view?.context?.toast(R.string.no_match_found) } + } + } + } else { + TrackSearchDialog(this, item.service, item.track != null) + .showDialog(controller.router, TAG_SEARCH_CONTROLLER) + } } override fun onStatusClick(position: Int) { diff --git a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsTrackingController.kt b/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsTrackingController.kt index 4c55d167ce..5ce59cc282 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/ui/setting/SettingsTrackingController.kt +++ b/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 @@ -31,6 +32,12 @@ class SettingsTrackingController : titleRes = R.string.sync_chapters_after_reading defaultValue = true } + switchPreference { + key = Keys.autoAddTrack + titleRes = R.string.track_when_adding_to_library + summaryRes = R.string.only_applies_silent_trackers + defaultValue = true + } preferenceCategory { titleRes = R.string.services @@ -51,6 +58,10 @@ class SettingsTrackingController : trackPreference(trackManager.bangumi) { activity?.openInBrowser(BangumiApi.authUrl(), trackManager.bangumi.getLogoColor()) } + trackPreference(trackManager.komga) { + trackManager.komga.loginNoop() + updatePreference(trackManager.komga.id) + } } } @@ -66,9 +77,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() } diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/MangaExtensions.kt b/app/src/main/java/eu/kanade/tachiyomi/util/MangaExtensions.kt index 59ac1276cf..f7ceea3533 100644 --- a/app/src/main/java/eu/kanade/tachiyomi/util/MangaExtensions.kt +++ b/app/src/main/java/eu/kanade/tachiyomi/util/MangaExtensions.kt @@ -10,9 +10,19 @@ import eu.kanade.tachiyomi.data.database.models.Category import eu.kanade.tachiyomi.data.database.models.Manga import eu.kanade.tachiyomi.data.database.models.MangaCategory 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.SourceManager import eu.kanade.tachiyomi.ui.category.addtolibrary.SetCategoriesSheet +import eu.kanade.tachiyomi.util.chapter.syncChaptersWithTrackServiceTwoWay +import eu.kanade.tachiyomi.util.system.launchIO +import eu.kanade.tachiyomi.util.system.withUIContext import eu.kanade.tachiyomi.util.view.snack +import timber.log.Timber +import uy.kohesive.injekt.Injekt +import uy.kohesive.injekt.api.get import java.util.Date fun Manga.isLocal() = source == LocalSource.ID @@ -95,6 +105,9 @@ fun Manga.addOrRemoveToFavorites( defaultCategory != null -> { favorite = true date_added = Date().time + if (preferences.autoAddTrack()) { + autoAddTrack(db, onMangaMoved) + } db.insertManga(this).executeAsBlocking() val mc = MangaCategory.create(this, defaultCategory) db.setMangaCategories(listOf(mc), listOf(this)) @@ -108,6 +121,9 @@ fun Manga.addOrRemoveToFavorites( defaultCategoryId == 0 || categories.isEmpty() -> { // 'Default' or no category favorite = true date_added = Date().time + if (preferences.autoAddTrack()) { + autoAddTrack(db, onMangaMoved) + } db.insertManga(this).executeAsBlocking() db.setMangaCategories(emptyList(), listOf(this)) onMangaMoved() @@ -133,6 +149,9 @@ fun Manga.addOrRemoveToFavorites( true ) { onMangaAdded() + if (preferences.autoAddTrack()) { + autoAddTrack(db, onMangaMoved) + } }.show() } } @@ -163,3 +182,29 @@ fun Manga.addOrRemoveToFavorites( } return null } + +fun Manga.autoAddTrack(db: DatabaseHelper, onMangaMoved: () -> Unit) { + val loggedServices = Injekt.get().services.filter { it.isLogged } + val source = Injekt.get().getOrStub(this.source) + loggedServices + .filterIsInstance() + .filter { it.accept(source) } + .forEach { service -> + launchIO { + try { + service.match(this@autoAddTrack)?.let { track -> + track.manga_id = this@autoAddTrack.id!! + (service as TrackService).bind(track) + db.insertTrack(track).executeAsBlocking() + + syncChaptersWithTrackServiceTwoWay(db, db.getChapters(this@autoAddTrack).executeAsBlocking(), track, service as TrackService) + withUIContext { + onMangaMoved() + } + } + } catch (e: Exception) { + Timber.w(e, "Could not match manga: ${this@autoAddTrack.title} with service $service") + } + } + } +} diff --git a/app/src/main/java/eu/kanade/tachiyomi/util/chapter/ChapterTrackSync.kt b/app/src/main/java/eu/kanade/tachiyomi/util/chapter/ChapterTrackSync.kt new file mode 100644 index 0000000000..35906a5ed6 --- /dev/null +++ b/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.system.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, 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) + } + } +} diff --git a/app/src/main/res/drawable/ic_tracker_komga.webp b/app/src/main/res/drawable/ic_tracker_komga.webp new file mode 100644 index 0000000000..ecfaa4ec43 Binary files /dev/null and b/app/src/main/res/drawable/ic_tracker_komga.webp differ diff --git a/app/src/main/res/layout/track_item.xml b/app/src/main/res/layout/track_item.xml index 725f2dc254..bb976c5794 100644 --- a/app/src/main/res/layout/track_item.xml +++ b/app/src/main/res/layout/track_item.xml @@ -180,6 +180,7 @@ app:layout_constraintTop_toTopOf="parent" /> + app:layout_constraintTop_toBottomOf="@+id/track_chapters" /> Not tracked Services Sync chapters after reading + Track when adding to library + Only applies to silent trackers, such as Komga Reading Currently Reading Dropped @@ -532,6 +534,8 @@ Manga URL not set, please click title and select manga again Refresh tracking Add tracking + Source is not supported + No match found Remove tracking from app Also remove from %1$s AniList @@ -539,6 +543,7 @@ Kitsu Bangumi Shikimori + Komga Started reading date Finished reading date Please login to MAL again