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 <andreas.everos@gmail.com> * 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 <andreas.everos@gmail.com>
This commit is contained in:
parent
dbe8931cf0
commit
d6b3b0baf7
18 changed files with 481 additions and 7 deletions
|
@ -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.notification.Notifications
|
||||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||||
import eu.kanade.tachiyomi.data.track.TrackManager
|
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.SourceManager
|
||||||
import eu.kanade.tachiyomi.source.model.SManga
|
import eu.kanade.tachiyomi.source.model.SManga
|
||||||
import eu.kanade.tachiyomi.source.model.toSChapter
|
import eu.kanade.tachiyomi.source.model.toSChapter
|
||||||
import eu.kanade.tachiyomi.source.model.toSManga
|
import eu.kanade.tachiyomi.source.model.toSManga
|
||||||
import eu.kanade.tachiyomi.util.chapter.NoChaptersException
|
import eu.kanade.tachiyomi.util.chapter.NoChaptersException
|
||||||
import eu.kanade.tachiyomi.util.chapter.syncChaptersWithSource
|
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.prepUpdateCover
|
||||||
import eu.kanade.tachiyomi.util.shouldDownloadNewChapters
|
import eu.kanade.tachiyomi.util.shouldDownloadNewChapters
|
||||||
import eu.kanade.tachiyomi.util.storage.getUriCompat
|
import eu.kanade.tachiyomi.util.storage.getUriCompat
|
||||||
|
@ -416,6 +418,10 @@ class LibraryUpdateService(
|
||||||
try {
|
try {
|
||||||
val updatedTrack = service.refresh(track)
|
val updatedTrack = service.refresh(track)
|
||||||
db.insertTrack(updatedTrack).executeAsBlocking()
|
db.insertTrack(updatedTrack).executeAsBlocking()
|
||||||
|
|
||||||
|
if (service is UnattendedTrackService) {
|
||||||
|
syncChaptersWithTrackServiceTwoWay(db, db.getChapters(manga).executeAsBlocking(), track, service)
|
||||||
|
}
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
// Ignore errors and continue
|
// Ignore errors and continue
|
||||||
Timber.e(e)
|
Timber.e(e)
|
||||||
|
|
|
@ -97,6 +97,8 @@ object PreferenceKeys {
|
||||||
|
|
||||||
const val autoUpdateTrack = "pref_auto_update_manga_sync_key"
|
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 lastUsedSource = "last_catalogue_source"
|
||||||
|
|
||||||
const val lastUsedCategory = "last_used_category"
|
const val lastUsedCategory = "last_used_category"
|
||||||
|
|
|
@ -171,6 +171,8 @@ class PreferencesHelper(val context: Context) {
|
||||||
|
|
||||||
fun autoUpdateTrack() = prefs.getBoolean(Keys.autoUpdateTrack, true)
|
fun autoUpdateTrack() = prefs.getBoolean(Keys.autoUpdateTrack, true)
|
||||||
|
|
||||||
|
fun autoAddTrack() = prefs.getBoolean(Keys.autoAddTrack, true)
|
||||||
|
|
||||||
fun lastUsedSource() = flowPrefs.getLong(Keys.lastUsedSource, -1)
|
fun lastUsedSource() = flowPrefs.getLong(Keys.lastUsedSource, -1)
|
||||||
|
|
||||||
fun lastUsedCategory() = flowPrefs.getInt(Keys.lastUsedCategory, 0)
|
fun lastUsedCategory() = flowPrefs.getInt(Keys.lastUsedCategory, 0)
|
||||||
|
|
|
@ -0,0 +1,8 @@
|
||||||
|
package eu.kanade.tachiyomi.data.track
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A TrackService that doesn't need explicit login.
|
||||||
|
*/
|
||||||
|
interface NoLoginTrackService {
|
||||||
|
fun loginNoop()
|
||||||
|
}
|
|
@ -4,6 +4,7 @@ import android.content.Context
|
||||||
import eu.kanade.tachiyomi.data.track.anilist.Anilist
|
import eu.kanade.tachiyomi.data.track.anilist.Anilist
|
||||||
import eu.kanade.tachiyomi.data.track.bangumi.Bangumi
|
import eu.kanade.tachiyomi.data.track.bangumi.Bangumi
|
||||||
import eu.kanade.tachiyomi.data.track.kitsu.Kitsu
|
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.myanimelist.MyAnimeList
|
||||||
import eu.kanade.tachiyomi.data.track.shikimori.Shikimori
|
import eu.kanade.tachiyomi.data.track.shikimori.Shikimori
|
||||||
|
|
||||||
|
@ -15,6 +16,7 @@ class TrackManager(context: Context) {
|
||||||
const val KITSU = 3
|
const val KITSU = 3
|
||||||
const val SHIKIMORI = 4
|
const val SHIKIMORI = 4
|
||||||
const val BANGUMI = 5
|
const val BANGUMI = 5
|
||||||
|
const val KOMGA = 6
|
||||||
}
|
}
|
||||||
|
|
||||||
val myAnimeList = MyAnimeList(context, MYANIMELIST)
|
val myAnimeList = MyAnimeList(context, MYANIMELIST)
|
||||||
|
@ -27,7 +29,9 @@ class TrackManager(context: Context) {
|
||||||
|
|
||||||
val bangumi = Bangumi(context, BANGUMI)
|
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 }
|
fun getService(id: Int) = services.find { it.id == id }
|
||||||
|
|
||||||
|
|
|
@ -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?
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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
|
||||||
|
}
|
||||||
|
}
|
|
@ -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,
|
||||||
|
)
|
|
@ -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.MangaCategory
|
||||||
import eu.kanade.tachiyomi.data.database.models.toMangaInfo
|
import eu.kanade.tachiyomi.data.database.models.toMangaInfo
|
||||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
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.CatalogueSource
|
||||||
import eu.kanade.tachiyomi.source.SourceManager
|
import eu.kanade.tachiyomi.source.SourceManager
|
||||||
import eu.kanade.tachiyomi.source.model.Filter
|
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.TriStateItem
|
||||||
import eu.kanade.tachiyomi.ui.browse.source.filter.TriStateSectionItem
|
import eu.kanade.tachiyomi.ui.browse.source.filter.TriStateSectionItem
|
||||||
import eu.kanade.tachiyomi.util.chapter.ChapterSettingsHelper
|
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.launchIO
|
||||||
import eu.kanade.tachiyomi.util.lang.withUIContext
|
import eu.kanade.tachiyomi.util.lang.withUIContext
|
||||||
import eu.kanade.tachiyomi.util.removeCovers
|
import eu.kanade.tachiyomi.util.removeCovers
|
||||||
|
@ -102,6 +106,8 @@ open class BrowseSourcePresenter(
|
||||||
*/
|
*/
|
||||||
private var pageSubscription: Subscription? = null
|
private var pageSubscription: Subscription? = null
|
||||||
|
|
||||||
|
private val loggedServices by lazy { Injekt.get<TrackManager>().services.filter { it.isLogged } }
|
||||||
|
|
||||||
init {
|
init {
|
||||||
query = searchQuery ?: ""
|
query = searchQuery ?: ""
|
||||||
}
|
}
|
||||||
|
@ -260,11 +266,36 @@ open class BrowseSourcePresenter(
|
||||||
manga.removeCovers(coverCache)
|
manga.removeCovers(coverCache)
|
||||||
} else {
|
} else {
|
||||||
ChapterSettingsHelper.applySettingDefaults(manga)
|
ChapterSettingsHelper.applySettingDefaults(manga)
|
||||||
|
|
||||||
|
if (prefs.autoAddTrack()) {
|
||||||
|
autoAddTrack(manga)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
db.insertManga(manga).executeAsBlocking()
|
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.
|
* Set the filter states for the current source.
|
||||||
*
|
*
|
||||||
|
|
|
@ -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.DownloadService
|
||||||
import eu.kanade.tachiyomi.data.download.model.Download
|
import eu.kanade.tachiyomi.data.download.model.Download
|
||||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
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.data.track.model.TrackSearch
|
||||||
import eu.kanade.tachiyomi.databinding.MangaControllerBinding
|
import eu.kanade.tachiyomi.databinding.MangaControllerBinding
|
||||||
import eu.kanade.tachiyomi.source.LocalSource
|
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.ui.webview.WebViewActivity
|
||||||
import eu.kanade.tachiyomi.util.chapter.NoChaptersException
|
import eu.kanade.tachiyomi.util.chapter.NoChaptersException
|
||||||
import eu.kanade.tachiyomi.util.hasCustomCover
|
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.lang.launchUI
|
||||||
import eu.kanade.tachiyomi.util.system.getResourceColor
|
import eu.kanade.tachiyomi.util.system.getResourceColor
|
||||||
import eu.kanade.tachiyomi.util.system.toast
|
import eu.kanade.tachiyomi.util.system.toast
|
||||||
|
@ -507,6 +510,24 @@ class MangaController :
|
||||||
.showDialog(router)
|
.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")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
|
@ -17,6 +17,7 @@ import eu.kanade.tachiyomi.data.download.model.Download
|
||||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||||
import eu.kanade.tachiyomi.data.track.TrackManager
|
import eu.kanade.tachiyomi.data.track.TrackManager
|
||||||
import eu.kanade.tachiyomi.data.track.TrackService
|
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.LocalSource
|
||||||
import eu.kanade.tachiyomi.source.Source
|
import eu.kanade.tachiyomi.source.Source
|
||||||
import eu.kanade.tachiyomi.source.model.toSChapter
|
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.ui.manga.track.TrackItem
|
||||||
import eu.kanade.tachiyomi.util.chapter.ChapterSettingsHelper
|
import eu.kanade.tachiyomi.util.chapter.ChapterSettingsHelper
|
||||||
import eu.kanade.tachiyomi.util.chapter.syncChaptersWithSource
|
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.isLocal
|
||||||
import eu.kanade.tachiyomi.util.lang.launchIO
|
import eu.kanade.tachiyomi.util.lang.launchIO
|
||||||
import eu.kanade.tachiyomi.util.lang.withUIContext
|
import eu.kanade.tachiyomi.util.lang.withUIContext
|
||||||
|
@ -709,6 +711,10 @@ class MangaPresenter(
|
||||||
async {
|
async {
|
||||||
val track = it.service.refresh(it.track!!)
|
val track = it.service.refresh(it.track!!)
|
||||||
db.insertTrack(track).executeAsBlocking()
|
db.insertTrack(track).executeAsBlocking()
|
||||||
|
|
||||||
|
if (it.service is UnattendedTrackService) {
|
||||||
|
syncChaptersWithTrackServiceTwoWay(db, chapters, track, it.service)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
.awaitAll()
|
.awaitAll()
|
||||||
|
@ -740,6 +746,10 @@ class MangaPresenter(
|
||||||
try {
|
try {
|
||||||
service.bind(item)
|
service.bind(item)
|
||||||
db.insertTrack(item).executeAsBlocking()
|
db.insertTrack(item).executeAsBlocking()
|
||||||
|
|
||||||
|
if (service is UnattendedTrackService) {
|
||||||
|
syncChaptersWithTrackServiceTwoWay(db, chapters, item, service)
|
||||||
|
}
|
||||||
} catch (e: Throwable) {
|
} catch (e: Throwable) {
|
||||||
withUIContext { view?.applicationContext?.toast(e.message) }
|
withUIContext { view?.applicationContext?.toast(e.message) }
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,6 +3,7 @@ package eu.kanade.tachiyomi.ui.manga.track
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import androidx.core.view.isVisible
|
import androidx.core.view.isVisible
|
||||||
import androidx.recyclerview.widget.RecyclerView
|
import androidx.recyclerview.widget.RecyclerView
|
||||||
|
import eu.kanade.tachiyomi.R.string
|
||||||
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
import eu.kanade.tachiyomi.data.preference.PreferencesHelper
|
||||||
import eu.kanade.tachiyomi.databinding.TrackItemBinding
|
import eu.kanade.tachiyomi.databinding.TrackItemBinding
|
||||||
import uy.kohesive.injekt.injectLazy
|
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 "-"
|
if (track.total_chapters > 0) track.total_chapters else "-"
|
||||||
binding.trackStatus.text = item.service.getStatus(track.status)
|
binding.trackStatus.text = item.service.getStatus(track.status)
|
||||||
binding.trackScore.text = if (track.score == 0f) "-" else item.service.displayScore(track)
|
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) {
|
if (item.service.supportsReadingDates) {
|
||||||
binding.trackStartDate.text =
|
binding.trackStartDate.text =
|
||||||
|
|
|
@ -5,16 +5,25 @@ import android.view.LayoutInflater
|
||||||
import android.view.View
|
import android.view.View
|
||||||
import androidx.recyclerview.widget.LinearLayoutManager
|
import androidx.recyclerview.widget.LinearLayoutManager
|
||||||
import com.google.android.material.bottomsheet.BottomSheetBehavior
|
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.database.models.Manga
|
||||||
|
import eu.kanade.tachiyomi.data.track.UnattendedTrackService
|
||||||
import eu.kanade.tachiyomi.databinding.TrackControllerBinding
|
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.base.controller.openInBrowser
|
||||||
import eu.kanade.tachiyomi.ui.manga.MangaController
|
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.copyToClipboard
|
||||||
|
import eu.kanade.tachiyomi.util.system.toast
|
||||||
import eu.kanade.tachiyomi.widget.sheet.BaseBottomSheetDialog
|
import eu.kanade.tachiyomi.widget.sheet.BaseBottomSheetDialog
|
||||||
|
import uy.kohesive.injekt.Injekt
|
||||||
|
import uy.kohesive.injekt.api.get
|
||||||
|
|
||||||
class TrackSheet(
|
class TrackSheet(
|
||||||
val controller: MangaController,
|
val controller: MangaController,
|
||||||
val manga: Manga
|
val manga: Manga,
|
||||||
|
private val sourceManager: SourceManager = Injekt.get()
|
||||||
) : BaseBottomSheetDialog(controller.activity!!),
|
) : BaseBottomSheetDialog(controller.activity!!),
|
||||||
TrackAdapter.OnClickListener,
|
TrackAdapter.OnClickListener,
|
||||||
SetTrackStatusDialog.Listener,
|
SetTrackStatusDialog.Listener,
|
||||||
|
@ -69,8 +78,32 @@ class TrackSheet(
|
||||||
|
|
||||||
override fun onSetClick(position: Int) {
|
override fun onSetClick(position: Int) {
|
||||||
val item = adapter.getItem(position) ?: return
|
val item = adapter.getItem(position) ?: return
|
||||||
|
|
||||||
|
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)
|
TrackSearchDialog(controller, item.service).showDialog(controller.router, TAG_SEARCH_CONTROLLER)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
override fun onTitleLongClick(position: Int) {
|
override fun onTitleLongClick(position: Int) {
|
||||||
adapter.getItem(position)?.track?.title?.let {
|
adapter.getItem(position)?.track?.title?.let {
|
||||||
|
@ -94,7 +127,7 @@ class TrackSheet(
|
||||||
|
|
||||||
override fun onScoreClick(position: Int) {
|
override fun onScoreClick(position: Int) {
|
||||||
val item = adapter.getItem(position) ?: return
|
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)
|
SetTrackScoreDialog(controller, this, item).showDialog(controller.router)
|
||||||
}
|
}
|
||||||
|
|
|
@ -3,6 +3,7 @@ package eu.kanade.tachiyomi.ui.setting
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
import androidx.preference.PreferenceScreen
|
import androidx.preference.PreferenceScreen
|
||||||
import eu.kanade.tachiyomi.R
|
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.TrackManager
|
||||||
import eu.kanade.tachiyomi.data.track.TrackService
|
import eu.kanade.tachiyomi.data.track.TrackService
|
||||||
import eu.kanade.tachiyomi.data.track.anilist.AnilistApi
|
import eu.kanade.tachiyomi.data.track.anilist.AnilistApi
|
||||||
|
@ -38,6 +39,11 @@ class SettingsTrackingController :
|
||||||
titleRes = R.string.pref_auto_update_manga_sync
|
titleRes = R.string.pref_auto_update_manga_sync
|
||||||
defaultValue = true
|
defaultValue = true
|
||||||
}
|
}
|
||||||
|
switchPreference {
|
||||||
|
key = Keys.autoAddTrack
|
||||||
|
titleRes = R.string.pref_auto_add_track
|
||||||
|
defaultValue = true
|
||||||
|
}
|
||||||
preferenceCategory {
|
preferenceCategory {
|
||||||
titleRes = R.string.services
|
titleRes = R.string.services
|
||||||
|
|
||||||
|
@ -58,6 +64,10 @@ class SettingsTrackingController :
|
||||||
trackPreference(trackManager.bangumi) {
|
trackPreference(trackManager.bangumi) {
|
||||||
activity?.openInBrowser(BangumiApi.authUrl(), trackManager.bangumi.getLogoColor())
|
activity?.openInBrowser(BangumiApi.authUrl(), trackManager.bangumi.getLogoColor())
|
||||||
}
|
}
|
||||||
|
trackPreference(trackManager.komga) {
|
||||||
|
trackManager.komga.loginNoop()
|
||||||
|
updatePreference(trackManager.komga.id)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
preferenceCategory {
|
preferenceCategory {
|
||||||
infoPreference(R.string.tracking_info)
|
infoPreference(R.string.tracking_info)
|
||||||
|
@ -76,9 +86,14 @@ class SettingsTrackingController :
|
||||||
{
|
{
|
||||||
onClick {
|
onClick {
|
||||||
if (service.isLogged) {
|
if (service.isLogged) {
|
||||||
|
if (service is NoLoginTrackService) {
|
||||||
|
service.logout()
|
||||||
|
updatePreference(service.id)
|
||||||
|
} else {
|
||||||
val dialog = TrackLogoutDialog(service)
|
val dialog = TrackLogoutDialog(service)
|
||||||
dialog.targetController = this@SettingsTrackingController
|
dialog.targetController = this@SettingsTrackingController
|
||||||
dialog.showDialog(router)
|
dialog.showDialog(router)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
login()
|
login()
|
||||||
}
|
}
|
||||||
|
|
|
@ -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
Normal file
BIN
app/src/main/res/drawable-xhdpi/ic_tracker_komga.webp
Normal file
Binary file not shown.
After Width: | Height: | Size: 76 KiB |
|
@ -376,6 +376,7 @@
|
||||||
|
|
||||||
<!-- Tracking section -->
|
<!-- Tracking section -->
|
||||||
<string name="pref_auto_update_manga_sync">Update chapter progress after reading</string>
|
<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="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>
|
<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_anilist" translatable="false">AniList</string>
|
||||||
<string name="tracker_myanimelist" translatable="false">MyAnimeList</string>
|
<string name="tracker_myanimelist" translatable="false">MyAnimeList</string>
|
||||||
<string name="tracker_kitsu" translatable="false">Kitsu</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_bangumi" translatable="false">Bangumi</string>
|
||||||
<string name="tracker_shikimori" translatable="false">Shikimori</string>
|
<string name="tracker_shikimori" translatable="false">Shikimori</string>
|
||||||
<string name="manga_tracking_tab">Tracking</string>
|
<string name="manga_tracking_tab">Tracking</string>
|
||||||
|
@ -589,6 +591,7 @@
|
||||||
<item quantity="other">%d trackers</item>
|
<item quantity="other">%d trackers</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
<string name="add_tracking">Add tracking</string>
|
<string name="add_tracking">Add tracking</string>
|
||||||
|
<string name="unread">Unread</string>
|
||||||
<string name="reading">Reading</string>
|
<string name="reading">Reading</string>
|
||||||
<string name="currently_reading">Currently reading</string>
|
<string name="currently_reading">Currently reading</string>
|
||||||
<string name="completed">Completed</string>
|
<string name="completed">Completed</string>
|
||||||
|
@ -610,6 +613,9 @@
|
||||||
<string name="error_invalid_date_supplied">Invalid date supplied</string>
|
<string name="error_invalid_date_supplied">Invalid date supplied</string>
|
||||||
<string name="myanimelist_creds_missing">MAL login credentials not found</string>
|
<string name="myanimelist_creds_missing">MAL login credentials not found</string>
|
||||||
<string name="myanimelist_relogin">Please login to MAL again</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 -->
|
<!-- Category activity -->
|
||||||
<string name="error_category_exists">A category with this name already exists!</string>
|
<string name="error_category_exists">A category with this name already exists!</string>
|
||||||
|
|
Reference in a new issue