mirror of
				https://github.com/mihonapp/mihon.git
				synced 2025-11-03 23:58:55 +01:00 
			
		
		
		
	@@ -186,6 +186,24 @@ object SettingsTrackingScreen : SearchableSettings {
 | 
			
		||||
                        },
 | 
			
		||||
                        logout = trackManager.kavita::logout,
 | 
			
		||||
                    ),
 | 
			
		||||
 | 
			
		||||
                    Preference.PreferenceItem.TrackingPreference(
 | 
			
		||||
                        title = stringResource(trackManager.suwayomi.nameRes()),
 | 
			
		||||
                        service = trackManager.suwayomi,
 | 
			
		||||
                        login = {
 | 
			
		||||
                            val sourceManager = Injekt.get<SourceManager>()
 | 
			
		||||
                            val acceptedSources = trackManager.suwayomi.getAcceptedSources()
 | 
			
		||||
                            val hasValidSourceInstalled = sourceManager.getCatalogueSources()
 | 
			
		||||
                                .any { it::class.qualifiedName in acceptedSources }
 | 
			
		||||
 | 
			
		||||
                            if (hasValidSourceInstalled) {
 | 
			
		||||
                                trackManager.suwayomi.loginNoop()
 | 
			
		||||
                            } else {
 | 
			
		||||
                                context.toast(context.getString(R.string.enhanced_tracking_warning, context.getString(trackManager.suwayomi.nameRes())), Toast.LENGTH_LONG)
 | 
			
		||||
                            }
 | 
			
		||||
                        },
 | 
			
		||||
                        logout = trackManager.suwayomi::logout,
 | 
			
		||||
                    ),
 | 
			
		||||
                    Preference.PreferenceItem.InfoPreference(stringResource(R.string.enhanced_tracking_info)),
 | 
			
		||||
                ),
 | 
			
		||||
            ),
 | 
			
		||||
 
 | 
			
		||||
@@ -9,6 +9,7 @@ import eu.kanade.tachiyomi.data.track.komga.Komga
 | 
			
		||||
import eu.kanade.tachiyomi.data.track.mangaupdates.MangaUpdates
 | 
			
		||||
import eu.kanade.tachiyomi.data.track.myanimelist.MyAnimeList
 | 
			
		||||
import eu.kanade.tachiyomi.data.track.shikimori.Shikimori
 | 
			
		||||
import eu.kanade.tachiyomi.data.track.suwayomi.Suwayomi
 | 
			
		||||
 | 
			
		||||
class TrackManager(context: Context) {
 | 
			
		||||
 | 
			
		||||
@@ -21,6 +22,7 @@ class TrackManager(context: Context) {
 | 
			
		||||
        const val KOMGA = 6L
 | 
			
		||||
        const val MANGA_UPDATES = 7L
 | 
			
		||||
        const val KAVITA = 8L
 | 
			
		||||
        const val SUWAYOMI = 9L
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    val myAnimeList = MyAnimeList(context, MYANIMELIST)
 | 
			
		||||
@@ -31,8 +33,9 @@ class TrackManager(context: Context) {
 | 
			
		||||
    val komga = Komga(context, KOMGA)
 | 
			
		||||
    val mangaUpdates = MangaUpdates(context, MANGA_UPDATES)
 | 
			
		||||
    val kavita = Kavita(context, KAVITA)
 | 
			
		||||
    val suwayomi = Suwayomi(context, SUWAYOMI)
 | 
			
		||||
 | 
			
		||||
    val services = listOf(myAnimeList, aniList, kitsu, shikimori, bangumi, komga, mangaUpdates, kavita)
 | 
			
		||||
    val services = listOf(myAnimeList, aniList, kitsu, shikimori, bangumi, komga, mangaUpdates, kavita, suwayomi)
 | 
			
		||||
 | 
			
		||||
    fun getService(id: Long) = services.find { it.id == id }
 | 
			
		||||
 | 
			
		||||
 
 | 
			
		||||
@@ -0,0 +1,102 @@
 | 
			
		||||
package eu.kanade.tachiyomi.data.track.suwayomi
 | 
			
		||||
 | 
			
		||||
import android.content.Context
 | 
			
		||||
import android.graphics.Color
 | 
			
		||||
import androidx.annotation.StringRes
 | 
			
		||||
import eu.kanade.tachiyomi.R
 | 
			
		||||
import eu.kanade.tachiyomi.data.database.models.Track
 | 
			
		||||
import eu.kanade.tachiyomi.data.track.EnhancedTrackService
 | 
			
		||||
import eu.kanade.tachiyomi.data.track.NoLoginTrackService
 | 
			
		||||
import eu.kanade.tachiyomi.data.track.TrackService
 | 
			
		||||
import eu.kanade.tachiyomi.data.track.model.TrackSearch
 | 
			
		||||
import eu.kanade.tachiyomi.source.Source
 | 
			
		||||
import eu.kanade.domain.manga.model.Manga as DomainManga
 | 
			
		||||
import eu.kanade.domain.track.model.Track as DomainTrack
 | 
			
		||||
 | 
			
		||||
class Suwayomi(private val context: Context, id: Long) : TrackService(id), NoLoginTrackService, EnhancedTrackService {
 | 
			
		||||
    val api by lazy { TachideskApi() }
 | 
			
		||||
 | 
			
		||||
    @StringRes
 | 
			
		||||
    override fun nameRes() = R.string.tracker_suwayomi
 | 
			
		||||
 | 
			
		||||
    override fun getLogo() = R.drawable.ic_tracker_suwayomi
 | 
			
		||||
 | 
			
		||||
    override fun getLogoColor() = Color.rgb(255, 35, 35) // TODO
 | 
			
		||||
 | 
			
		||||
    companion object {
 | 
			
		||||
        const val UNREAD = 1
 | 
			
		||||
        const val READING = 2
 | 
			
		||||
        const val COMPLETED = 3
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    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.reading)
 | 
			
		||||
            COMPLETED -> getString(R.string.completed)
 | 
			
		||||
            else -> ""
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    override fun getReadingStatus(): Int = READING
 | 
			
		||||
 | 
			
		||||
    override fun getRereadingStatus(): Int = -1
 | 
			
		||||
 | 
			
		||||
    override fun getCompletionStatus(): Int = COMPLETED
 | 
			
		||||
 | 
			
		||||
    override fun getScoreList(): List<String> = emptyList()
 | 
			
		||||
 | 
			
		||||
    override fun displayScore(track: Track): String = ""
 | 
			
		||||
 | 
			
		||||
    override suspend fun update(track: Track, didReadChapter: Boolean): Track {
 | 
			
		||||
        if (track.status != COMPLETED) {
 | 
			
		||||
            if (didReadChapter) {
 | 
			
		||||
                if (track.last_chapter_read.toInt() == track.total_chapters && track.total_chapters > 0) {
 | 
			
		||||
                    track.status = COMPLETED
 | 
			
		||||
                } else {
 | 
			
		||||
                    track.status = READING
 | 
			
		||||
                }
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        return api.updateProgress(track)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    override suspend fun bind(track: Track, hasReadChapters: Boolean): Track {
 | 
			
		||||
        return track
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    override suspend fun search(query: String): List<TrackSearch> {
 | 
			
		||||
        TODO("Not yet implemented")
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    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")
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    override fun loginNoop() {
 | 
			
		||||
        saveCredentials("user", "pass")
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    override fun getAcceptedSources(): List<String> = listOf("eu.kanade.tachiyomi.extension.all.tachidesk.Tachidesk")
 | 
			
		||||
 | 
			
		||||
    override suspend fun match(manga: DomainManga): TrackSearch = api.getTrackSearch(manga.url)
 | 
			
		||||
 | 
			
		||||
    override fun isTrackFrom(track: DomainTrack, manga: DomainManga, source: Source?): Boolean = source?.let { accept(it) } == true
 | 
			
		||||
 | 
			
		||||
    override fun migrateTrack(track: DomainTrack, manga: DomainManga, newSource: Source): DomainTrack? =
 | 
			
		||||
        if (accept(newSource)) {
 | 
			
		||||
            track.copy(remoteUrl = manga.url)
 | 
			
		||||
        } else {
 | 
			
		||||
            null
 | 
			
		||||
        }
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,113 @@
 | 
			
		||||
package eu.kanade.tachiyomi.data.track.suwayomi
 | 
			
		||||
 | 
			
		||||
import android.app.Application
 | 
			
		||||
import android.content.SharedPreferences
 | 
			
		||||
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.NetworkHelper
 | 
			
		||||
import eu.kanade.tachiyomi.network.PUT
 | 
			
		||||
import eu.kanade.tachiyomi.network.await
 | 
			
		||||
import eu.kanade.tachiyomi.network.parseAs
 | 
			
		||||
import eu.kanade.tachiyomi.util.lang.withIOContext
 | 
			
		||||
import okhttp3.Credentials
 | 
			
		||||
import okhttp3.Dns
 | 
			
		||||
import okhttp3.FormBody
 | 
			
		||||
import okhttp3.Headers
 | 
			
		||||
import okhttp3.OkHttpClient
 | 
			
		||||
import uy.kohesive.injekt.Injekt
 | 
			
		||||
import uy.kohesive.injekt.api.get
 | 
			
		||||
import uy.kohesive.injekt.injectLazy
 | 
			
		||||
import java.nio.charset.Charset
 | 
			
		||||
import java.security.MessageDigest
 | 
			
		||||
 | 
			
		||||
class TachideskApi {
 | 
			
		||||
    private val network by injectLazy<NetworkHelper>()
 | 
			
		||||
    val client: OkHttpClient =
 | 
			
		||||
        network.client.newBuilder()
 | 
			
		||||
            .dns(Dns.SYSTEM) // don't use DNS over HTTPS as it breaks IP addressing
 | 
			
		||||
            .build()
 | 
			
		||||
    fun headersBuilder(): Headers.Builder = Headers.Builder().apply {
 | 
			
		||||
        add("User-Agent", network.defaultUserAgent)
 | 
			
		||||
        if (basePassword.isNotEmpty() && baseLogin.isNotEmpty()) {
 | 
			
		||||
            val credentials = Credentials.basic(baseLogin, basePassword)
 | 
			
		||||
            add("Authorization", credentials)
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    val headers: Headers by lazy { headersBuilder().build() }
 | 
			
		||||
 | 
			
		||||
    private val baseUrl by lazy { getPrefBaseUrl() }
 | 
			
		||||
    private val baseLogin by lazy { getPrefBaseLogin() }
 | 
			
		||||
    private val basePassword by lazy { getPrefBasePassword() }
 | 
			
		||||
 | 
			
		||||
    suspend fun getTrackSearch(trackUrl: String): TrackSearch = withIOContext {
 | 
			
		||||
        val url = try {
 | 
			
		||||
            // test if getting api url or manga id
 | 
			
		||||
            val mangaId = trackUrl.toLong()
 | 
			
		||||
            "$baseUrl/api/v1/manga/$mangaId"
 | 
			
		||||
        } catch (e: NumberFormatException) {
 | 
			
		||||
            trackUrl
 | 
			
		||||
        }
 | 
			
		||||
 | 
			
		||||
        val manga = client.newCall(GET("$url/full", headers)).await().parseAs<MangaDataClass>()
 | 
			
		||||
 | 
			
		||||
        TrackSearch.create(TrackManager.SUWAYOMI).apply {
 | 
			
		||||
            title = manga.title
 | 
			
		||||
            cover_url = "$url/thumbnail"
 | 
			
		||||
            summary = manga.description
 | 
			
		||||
            tracking_url = url
 | 
			
		||||
            total_chapters = manga.chapterCount.toInt()
 | 
			
		||||
            publishing_status = manga.status
 | 
			
		||||
            last_chapter_read = manga.lastChapterRead?.chapterNumber ?: 0F
 | 
			
		||||
            status = when (manga.unreadCount) {
 | 
			
		||||
                manga.chapterCount -> Suwayomi.UNREAD
 | 
			
		||||
                0L -> Suwayomi.COMPLETED
 | 
			
		||||
                else -> Suwayomi.READING
 | 
			
		||||
            }
 | 
			
		||||
        }
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    suspend fun updateProgress(track: Track): Track {
 | 
			
		||||
        val url = track.tracking_url
 | 
			
		||||
        val chapters = client.newCall(GET("$url/chapters", headers)).await().parseAs<List<ChapterDataClass>>()
 | 
			
		||||
        val lastChapterIndex = chapters.first { it.chapterNumber == track.last_chapter_read }.index
 | 
			
		||||
 | 
			
		||||
        client.newCall(
 | 
			
		||||
            PUT(
 | 
			
		||||
                "$url/chapter/$lastChapterIndex",
 | 
			
		||||
                headers,
 | 
			
		||||
                FormBody.Builder(Charset.forName("utf8"))
 | 
			
		||||
                    .add("markPrevRead", "true")
 | 
			
		||||
                    .add("read", "true")
 | 
			
		||||
                    .build(),
 | 
			
		||||
            ),
 | 
			
		||||
        ).await()
 | 
			
		||||
 | 
			
		||||
        return getTrackSearch(track.tracking_url)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    val tachideskExtensionId by lazy {
 | 
			
		||||
        val key = "tachidesk/en/1"
 | 
			
		||||
        val bytes = MessageDigest.getInstance("MD5").digest(key.toByteArray())
 | 
			
		||||
        (0..7).map { bytes[it].toLong() and 0xff shl 8 * (7 - it) }.reduce(Long::or) and Long.MAX_VALUE
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private val preferences: SharedPreferences by lazy {
 | 
			
		||||
        Injekt.get<Application>().getSharedPreferences("source_$tachideskExtensionId", 0x0000)
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    companion object {
 | 
			
		||||
        private const val ADDRESS_TITLE = "Server URL Address"
 | 
			
		||||
        private const val ADDRESS_DEFAULT = ""
 | 
			
		||||
        private const val LOGIN_TITLE = "Login (Basic Auth)"
 | 
			
		||||
        private const val LOGIN_DEFAULT = ""
 | 
			
		||||
        private const val PASSWORD_TITLE = "Password (Basic Auth)"
 | 
			
		||||
        private const val PASSWORD_DEFAULT = ""
 | 
			
		||||
    }
 | 
			
		||||
 | 
			
		||||
    private fun getPrefBaseUrl(): String = preferences.getString(ADDRESS_TITLE, ADDRESS_DEFAULT)!!
 | 
			
		||||
    private fun getPrefBaseLogin(): String = preferences.getString(LOGIN_TITLE, LOGIN_DEFAULT)!!
 | 
			
		||||
    private fun getPrefBasePassword(): String = preferences.getString(PASSWORD_TITLE, PASSWORD_DEFAULT)!!
 | 
			
		||||
}
 | 
			
		||||
@@ -0,0 +1,97 @@
 | 
			
		||||
package eu.kanade.tachiyomi.data.track.suwayomi
 | 
			
		||||
 | 
			
		||||
import kotlinx.serialization.Serializable
 | 
			
		||||
 | 
			
		||||
@Serializable
 | 
			
		||||
data class SourceDataClass(
 | 
			
		||||
    val id: String,
 | 
			
		||||
    val name: String?,
 | 
			
		||||
    val lang: String?,
 | 
			
		||||
    val iconUrl: String?,
 | 
			
		||||
 | 
			
		||||
    /** The Source provides a latest listing */
 | 
			
		||||
    val supportsLatest: Boolean?,
 | 
			
		||||
 | 
			
		||||
    /** The Source implements [ConfigurableSource] */
 | 
			
		||||
    val isConfigurable: Boolean?,
 | 
			
		||||
 | 
			
		||||
    /** The Source class has a @Nsfw annotation */
 | 
			
		||||
    val isNsfw: Boolean?,
 | 
			
		||||
 | 
			
		||||
    /** A nicer version of [name] */
 | 
			
		||||
    val displayName: String?,
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
@Serializable
 | 
			
		||||
data class MangaDataClass(
 | 
			
		||||
    val id: Int,
 | 
			
		||||
    val sourceId: String,
 | 
			
		||||
 | 
			
		||||
    val url: String,
 | 
			
		||||
    val title: String,
 | 
			
		||||
    val thumbnailUrl: String,
 | 
			
		||||
 | 
			
		||||
    val initialized: Boolean,
 | 
			
		||||
 | 
			
		||||
    val artist: String,
 | 
			
		||||
    val author: String,
 | 
			
		||||
    val description: String,
 | 
			
		||||
    val genre: List<String>,
 | 
			
		||||
    val status: String,
 | 
			
		||||
    val inLibrary: Boolean,
 | 
			
		||||
    val inLibraryAt: Long,
 | 
			
		||||
    val source: SourceDataClass,
 | 
			
		||||
 | 
			
		||||
    val meta: Map<String, String> = emptyMap(),
 | 
			
		||||
 | 
			
		||||
    val realUrl: String,
 | 
			
		||||
    var lastFetchedAt: Long,
 | 
			
		||||
    var chaptersLastFetchedAt: Long,
 | 
			
		||||
 | 
			
		||||
    val freshData: Boolean,
 | 
			
		||||
    val unreadCount: Long,
 | 
			
		||||
    val downloadCount: Long,
 | 
			
		||||
    val chapterCount: Long,
 | 
			
		||||
    val lastChapterRead: ChapterDataClass?,
 | 
			
		||||
 | 
			
		||||
    val age: Long,
 | 
			
		||||
    val chaptersAge: Long,
 | 
			
		||||
)
 | 
			
		||||
 | 
			
		||||
@Serializable
 | 
			
		||||
data class ChapterDataClass(
 | 
			
		||||
    val id: Int,
 | 
			
		||||
    val url: String,
 | 
			
		||||
    val name: String,
 | 
			
		||||
    val uploadDate: Long,
 | 
			
		||||
    val chapterNumber: Float,
 | 
			
		||||
    val scanlator: String?,
 | 
			
		||||
    val mangaId: Int,
 | 
			
		||||
 | 
			
		||||
    /** chapter is read */
 | 
			
		||||
    val read: Boolean,
 | 
			
		||||
 | 
			
		||||
    /** chapter is bookmarked */
 | 
			
		||||
    val bookmarked: Boolean,
 | 
			
		||||
 | 
			
		||||
    /** last read page, zero means not read/no data */
 | 
			
		||||
    val lastPageRead: Int,
 | 
			
		||||
 | 
			
		||||
    /** last read page, zero means not read/no data */
 | 
			
		||||
    val lastReadAt: Long,
 | 
			
		||||
 | 
			
		||||
    /** this chapter's index, starts with 1 */
 | 
			
		||||
    val index: Int,
 | 
			
		||||
 | 
			
		||||
    /** the date we fist saw this chapter*/
 | 
			
		||||
    val fetchedAt: Long,
 | 
			
		||||
 | 
			
		||||
    /** is chapter downloaded */
 | 
			
		||||
    val downloaded: Boolean,
 | 
			
		||||
 | 
			
		||||
    /** used to construct pages in the front-end */
 | 
			
		||||
    val pageCount: Int,
 | 
			
		||||
 | 
			
		||||
    /** total chapter count, used to calculate if there's a next and prev chapter */
 | 
			
		||||
    val chapterCount: Int,
 | 
			
		||||
)
 | 
			
		||||
							
								
								
									
										
											BIN
										
									
								
								app/src/main/res/drawable-nodpi/ic_tracker_suwayomi.webp
									
									
									
									
									
										Normal file
									
								
							
							
						
						
									
										
											BIN
										
									
								
								app/src/main/res/drawable-nodpi/ic_tracker_suwayomi.webp
									
									
									
									
									
										Normal file
									
								
							
										
											Binary file not shown.
										
									
								
							| 
		 After Width: | Height: | Size: 12 KiB  | 
@@ -682,6 +682,7 @@
 | 
			
		||||
    <string name="tracker_shikimori" translatable="false">Shikimori</string>
 | 
			
		||||
    <string name="tracker_manga_updates" translatable="false">MangaUpdates</string>
 | 
			
		||||
    <string name="tracker_kavita" translatable="false">Kavita</string>
 | 
			
		||||
    <string name="tracker_suwayomi" translatable="false">Suwayomi</string>
 | 
			
		||||
    <string name="manga_tracking_tab">Tracking</string>
 | 
			
		||||
    <plurals name="num_trackers">
 | 
			
		||||
        <item quantity="one">%d tracker</item>
 | 
			
		||||
 
 | 
			
		||||
		Reference in New Issue
	
	Block a user