feat(tracker): Add Jellyfin enhanced tracker (#1341)

This commit is contained in:
Secozzi 2024-01-24 13:22:13 +00:00 committed by GitHub
parent b505d5965e
commit c2ab0db7a2
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
6 changed files with 319 additions and 9 deletions

View file

@ -42,6 +42,7 @@ import androidx.compose.ui.unit.dp
import dev.icerock.moko.resources.StringResource import dev.icerock.moko.resources.StringResource
import eu.kanade.domain.track.service.TrackPreferences import eu.kanade.domain.track.service.TrackPreferences
import eu.kanade.presentation.more.settings.Preference import eu.kanade.presentation.more.settings.Preference
import eu.kanade.tachiyomi.data.track.EnhancedAnimeTracker
import eu.kanade.tachiyomi.data.track.EnhancedMangaTracker import eu.kanade.tachiyomi.data.track.EnhancedMangaTracker
import eu.kanade.tachiyomi.data.track.Tracker import eu.kanade.tachiyomi.data.track.Tracker
import eu.kanade.tachiyomi.data.track.TrackerManager import eu.kanade.tachiyomi.data.track.TrackerManager
@ -57,6 +58,7 @@ import kotlinx.collections.immutable.toImmutableList
import tachiyomi.core.i18n.stringResource import tachiyomi.core.i18n.stringResource
import tachiyomi.core.util.lang.launchIO import tachiyomi.core.util.lang.launchIO
import tachiyomi.core.util.lang.withUIContext import tachiyomi.core.util.lang.withUIContext
import tachiyomi.domain.source.anime.service.AnimeSourceManager
import tachiyomi.domain.source.manga.service.MangaSourceManager import tachiyomi.domain.source.manga.service.MangaSourceManager
import tachiyomi.i18n.MR import tachiyomi.i18n.MR
import tachiyomi.presentation.core.components.material.padding import tachiyomi.presentation.core.components.material.padding
@ -86,7 +88,8 @@ object SettingsTrackingScreen : SearchableSettings {
val context = LocalContext.current val context = LocalContext.current
val trackPreferences = remember { Injekt.get<TrackPreferences>() } val trackPreferences = remember { Injekt.get<TrackPreferences>() }
val trackerManager = remember { Injekt.get<TrackerManager>() } val trackerManager = remember { Injekt.get<TrackerManager>() }
val sourceManager = remember { Injekt.get<MangaSourceManager>() } val mangaSourceManager = remember { Injekt.get<MangaSourceManager>() }
val animeSourceManager = remember { Injekt.get<AnimeSourceManager>() }
var dialog by remember { mutableStateOf<Any?>(null) } var dialog by remember { mutableStateOf<Any?>(null) }
dialog?.run { dialog?.run {
@ -111,15 +114,22 @@ object SettingsTrackingScreen : SearchableSettings {
.filter { it is EnhancedMangaTracker } .filter { it is EnhancedMangaTracker }
.partition { service -> .partition { service ->
val acceptedMangaSources = (service as EnhancedMangaTracker).getAcceptedSources() val acceptedMangaSources = (service as EnhancedMangaTracker).getAcceptedSources()
sourceManager.getCatalogueSources().any { it::class.qualifiedName in acceptedMangaSources } mangaSourceManager.getCatalogueSources().any { it::class.qualifiedName in acceptedMangaSources }
} }
var enhancedMangaTrackerInfo = stringResource(MR.strings.enhanced_tracking_info) val enhancedAnimeTrackers = trackerManager.trackers
if (enhancedMangaTrackers.second.isNotEmpty()) { .filter { it is EnhancedAnimeTracker }
val missingMangaSourcesInfo = stringResource( .partition { service ->
val acceptedAnimeSources = (service as EnhancedAnimeTracker).getAcceptedSources()
animeSourceManager.getCatalogueSources().any { it::class.qualifiedName in acceptedAnimeSources }
}
var enhancedTrackerInfo = stringResource(MR.strings.enhanced_tracking_info)
if (enhancedMangaTrackers.second.isNotEmpty() || enhancedAnimeTrackers.second.isNotEmpty()) {
val missingSourcesInfo = stringResource(
MR.strings.enhanced_services_not_installed, MR.strings.enhanced_services_not_installed,
enhancedMangaTrackers.second.joinToString { it.name }, (enhancedMangaTrackers.second + enhancedAnimeTrackers.second).joinToString { it.name },
) )
enhancedMangaTrackerInfo += "\n\n$missingMangaSourcesInfo" enhancedTrackerInfo += "\n\n$missingSourcesInfo"
} }
return listOf( return listOf(
@ -219,7 +229,16 @@ object SettingsTrackingScreen : SearchableSettings {
login = { (service as EnhancedMangaTracker).loginNoop() }, login = { (service as EnhancedMangaTracker).loginNoop() },
logout = service::logout, logout = service::logout,
) )
} + listOf(Preference.PreferenceItem.InfoPreference(enhancedMangaTrackerInfo)) } +
enhancedAnimeTrackers.first
.map { service ->
Preference.PreferenceItem.TrackerPreference(
title = service.name,
tracker = service,
login = { (service as EnhancedAnimeTracker).loginNoop() },
logout = service::logout,
)
} + listOf(Preference.PreferenceItem.InfoPreference(enhancedTrackerInfo))
).toImmutableList(), ).toImmutableList(),
), ),
) )

View file

@ -3,6 +3,7 @@ package eu.kanade.tachiyomi.data.track
import android.content.Context 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.jellyfin.Jellyfin
import eu.kanade.tachiyomi.data.track.kavita.Kavita import eu.kanade.tachiyomi.data.track.kavita.Kavita
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.komga.Komga
@ -19,6 +20,7 @@ class TrackerManager(context: Context) {
const val KITSU = 3L const val KITSU = 3L
const val KAVITA = 8L const val KAVITA = 8L
const val SIMKL = 101L const val SIMKL = 101L
const val JELLYFIN = 102L
} }
val myAnimeList = MyAnimeList(1L) val myAnimeList = MyAnimeList(1L)
@ -31,8 +33,12 @@ class TrackerManager(context: Context) {
val kavita = Kavita(KAVITA) val kavita = Kavita(KAVITA)
val suwayomi = Suwayomi(9L) val suwayomi = Suwayomi(9L)
val simkl = Simkl(SIMKL) val simkl = Simkl(SIMKL)
val jellyfin = Jellyfin(JELLYFIN)
val trackers = listOf(myAnimeList, aniList, kitsu, shikimori, bangumi, komga, mangaUpdates, kavita, suwayomi, simkl) val trackers = listOf(
myAnimeList, aniList, kitsu, shikimori, bangumi,
komga, mangaUpdates, kavita, suwayomi, simkl, jellyfin,
)
fun loggedInTrackers() = trackers.filter { it.isLoggedIn } fun loggedInTrackers() = trackers.filter { it.isLoggedIn }

View file

@ -0,0 +1,103 @@
package eu.kanade.tachiyomi.data.track.jellyfin
import android.graphics.Color
import dev.icerock.moko.resources.StringResource
import eu.kanade.tachiyomi.R
import eu.kanade.tachiyomi.animesource.AnimeSource
import eu.kanade.tachiyomi.data.database.models.anime.AnimeTrack
import eu.kanade.tachiyomi.data.track.AnimeTracker
import eu.kanade.tachiyomi.data.track.BaseTracker
import eu.kanade.tachiyomi.data.track.EnhancedAnimeTracker
import eu.kanade.tachiyomi.data.track.model.AnimeTrackSearch
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import okhttp3.Dns
import okhttp3.OkHttpClient
import tachiyomi.domain.entries.anime.model.Anime
import tachiyomi.i18n.MR
import tachiyomi.domain.track.anime.model.AnimeTrack as DomainTrack
class Jellyfin(id: Long) : BaseTracker(id, "Jellyfin"), EnhancedAnimeTracker, AnimeTracker {
companion object {
const val UNSEEN = 1
const val WATCHING = 2
const val COMPLETED = 3
}
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 { JellyfinApi(id, client) }
override fun getLogo() = R.drawable.ic_tracker_jellyfin
override fun getLogoColor() = Color.rgb(0, 11, 37)
override fun getStatusListAnime() = listOf(UNSEEN, WATCHING, COMPLETED)
override fun getStatus(status: Int): StringResource? = when (status) {
UNSEEN -> MR.strings.unseen
WATCHING -> MR.strings.watching
COMPLETED -> MR.strings.completed
else -> null
}
override fun getWatchingStatus(): Int = WATCHING
override fun getRewatchingStatus(): Int = -1
override fun getCompletionStatus(): Int = COMPLETED
override fun getScoreList(): ImmutableList<String> = persistentListOf()
override fun displayScore(track: DomainTrack): String = ""
override suspend fun update(track: AnimeTrack, didWatchEpisode: Boolean): AnimeTrack {
return api.updateProgress(track)
}
override suspend fun bind(track: AnimeTrack, hasSeenEpisodes: Boolean): AnimeTrack {
return track
}
override suspend fun searchAnime(query: String): List<AnimeTrackSearch> =
throw Exception("Not used")
override suspend fun refresh(track: AnimeTrack): AnimeTrack {
val remoteTrack = api.getTrackSearch(track.tracking_url)
track.copyPersonalFrom(remoteTrack)
track.total_episodes = remoteTrack.total_episodes
return track
}
override suspend fun login(username: String, password: String) {
saveCredentials("user", "pass")
}
override fun loginNoop() {
saveCredentials("user", "pass")
}
override fun getAcceptedSources() = listOf("eu.kanade.tachiyomi.animeextension.all.jellyfin.Jellyfin")
override suspend fun match(anime: Anime): AnimeTrackSearch? =
try {
api.getTrackSearch(anime.url)
} catch (e: Exception) {
null
}
override fun isTrackFrom(track: DomainTrack, anime: Anime, source: AnimeSource?): Boolean =
track.remoteUrl == anime.url && source?.let { accept(it) } == true
override fun migrateTrack(track: DomainTrack, anime: Anime, newSource: AnimeSource): DomainTrack? {
return if (accept(newSource)) {
track.copy(remoteUrl = anime.url)
} else {
null
}
}
}

View file

@ -0,0 +1,160 @@
package eu.kanade.tachiyomi.data.track.jellyfin
import eu.kanade.tachiyomi.data.database.models.anime.AnimeTrack
import eu.kanade.tachiyomi.data.track.model.AnimeTrackSearch
import eu.kanade.tachiyomi.network.GET
import eu.kanade.tachiyomi.network.POST
import eu.kanade.tachiyomi.network.awaitSuccess
import eu.kanade.tachiyomi.network.parseAs
import kotlinx.serialization.json.Json
import logcat.LogPriority
import okhttp3.HttpUrl
import okhttp3.HttpUrl.Companion.toHttpUrl
import okhttp3.OkHttpClient
import tachiyomi.core.util.lang.withIOContext
import tachiyomi.core.util.system.logcat
import uy.kohesive.injekt.injectLazy
import java.text.SimpleDateFormat
import java.util.Date
import kotlin.math.abs
class JellyfinApi(
private val trackId: Long,
private val client: OkHttpClient,
) {
private val json: Json by injectLazy()
suspend fun getTrackSearch(url: String): AnimeTrackSearch =
withIOContext {
try {
val httpUrl = url.toHttpUrl()
val fragment = httpUrl.fragment!!
val track = with(json) {
client.newCall(GET(url))
.awaitSuccess()
.parseAs<ItemDto>()
.toTrack()
}.apply { tracking_url = url }
when {
fragment.startsWith("seriesId") -> {
getTrackFromSeries(track, httpUrl)
}
else -> track
}
} catch (e: Exception) {
logcat(LogPriority.WARN, e) { "Could not get item: $url" }
throw e
}
}
private fun ItemDto.toTrack(): AnimeTrackSearch = AnimeTrackSearch.create(
trackId,
).also {
it.title = name
it.total_episodes = 1
if (userData.played) {
it.last_episode_seen = 1F
it.status = Jellyfin.COMPLETED
} else {
it.last_episode_seen = 0F
it.status = Jellyfin.UNSEEN
}
}
private fun getEpisodesUrl(url: HttpUrl): HttpUrl {
val apiKey = url.queryParameter("api_key")!!
val fragment = url.fragment!!
return url.newBuilder().apply {
encodedPath("/")
fragment(null)
encodedQuery(null)
addPathSegment("Shows")
addPathSegment(fragment.split(",").last())
addPathSegment("Episodes")
addQueryParameter("api_key", apiKey)
addQueryParameter("seasonId", url.pathSegments.last())
addQueryParameter("userId", url.pathSegments[1])
addQueryParameter("Fields", "Overview,MediaSources")
}.build()
}
private suspend fun getTrackFromSeries(track: AnimeTrackSearch, url: HttpUrl): AnimeTrackSearch {
val episodesUrl = getEpisodesUrl(url)
val episodes = with(json) {
client.newCall(GET(episodesUrl))
.awaitSuccess()
.parseAs<ItemsDto>()
}.items
val totalEpisodes = episodes.last().indexNumber!!
val firstUnwatched = episodes.indexOfFirst { !it.userData.played }
if (firstUnwatched == 0) {
return track.apply {
this.total_episodes = totalEpisodes
this.last_episode_seen = 0F
this.status = Jellyfin.UNSEEN
}
}
if (firstUnwatched == -1) {
return track.apply {
this.total_episodes = totalEpisodes
this.last_episode_seen = totalEpisodes.toFloat()
this.status = Jellyfin.COMPLETED
}
}
val lastContinuousSeen = episodes[firstUnwatched - 1].indexNumber!!
return track.apply {
this.total_episodes = totalEpisodes
this.last_episode_seen = lastContinuousSeen.toFloat()
this.status = Jellyfin.WATCHING
}
}
suspend fun updateProgress(track: AnimeTrack): AnimeTrack {
val httpUrl = track.tracking_url.toHttpUrl()
val fragment = httpUrl.fragment!!
val itemId = if (fragment.startsWith("movie")) {
httpUrl.pathSegments.last()
} else {
val episodesUrl = getEpisodesUrl(httpUrl)
val episodes = with(json) {
client.newCall(GET(episodesUrl))
.awaitSuccess()
.parseAs<ItemsDto>()
}.items
episodes.first {
it.indexNumber!!.equalsTo(track.last_episode_seen)
}.id
}
val time = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'").format(Date())
val postUrl = httpUrl.newBuilder().apply {
fragment(null)
removePathSegment(3)
removePathSegment(2)
addPathSegment("PlayedItems")
addPathSegment(itemId)
addQueryParameter("DatePlayed", time)
}.build().toString()
client.newCall(
POST(postUrl),
).awaitSuccess()
return getTrackSearch(track.tracking_url)
}
private fun Int.equalsTo(other: Float): Boolean {
return abs(this - other) < 0.001
}
}

View file

@ -0,0 +1,22 @@
package eu.kanade.tachiyomi.data.track.jellyfin
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
@Serializable
data class ItemDto(
@SerialName("Name") val name: String,
@SerialName("Id") val id: String,
@SerialName("UserData") val userData: UserDataDto,
@SerialName("IndexNumber") val indexNumber: Int? = null,
)
@Serializable
data class UserDataDto(
@SerialName("Played") val played: Boolean,
)
@Serializable
data class ItemsDto(
@SerialName("Items") val items: List<ItemDto>,
)

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB