mirror of
https://github.com/aniyomiorg/aniyomi.git
synced 2024-11-25 22:29:45 +03:00
feat(tracker): Add Jellyfin enhanced tracker (#1341)
This commit is contained in:
parent
b505d5965e
commit
c2ab0db7a2
6 changed files with 319 additions and 9 deletions
|
@ -42,6 +42,7 @@ import androidx.compose.ui.unit.dp
|
|||
import dev.icerock.moko.resources.StringResource
|
||||
import eu.kanade.domain.track.service.TrackPreferences
|
||||
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.Tracker
|
||||
import eu.kanade.tachiyomi.data.track.TrackerManager
|
||||
|
@ -57,6 +58,7 @@ import kotlinx.collections.immutable.toImmutableList
|
|||
import tachiyomi.core.i18n.stringResource
|
||||
import tachiyomi.core.util.lang.launchIO
|
||||
import tachiyomi.core.util.lang.withUIContext
|
||||
import tachiyomi.domain.source.anime.service.AnimeSourceManager
|
||||
import tachiyomi.domain.source.manga.service.MangaSourceManager
|
||||
import tachiyomi.i18n.MR
|
||||
import tachiyomi.presentation.core.components.material.padding
|
||||
|
@ -86,7 +88,8 @@ object SettingsTrackingScreen : SearchableSettings {
|
|||
val context = LocalContext.current
|
||||
val trackPreferences = remember { Injekt.get<TrackPreferences>() }
|
||||
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) }
|
||||
dialog?.run {
|
||||
|
@ -111,15 +114,22 @@ object SettingsTrackingScreen : SearchableSettings {
|
|||
.filter { it is EnhancedMangaTracker }
|
||||
.partition { service ->
|
||||
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)
|
||||
if (enhancedMangaTrackers.second.isNotEmpty()) {
|
||||
val missingMangaSourcesInfo = stringResource(
|
||||
val enhancedAnimeTrackers = trackerManager.trackers
|
||||
.filter { it is EnhancedAnimeTracker }
|
||||
.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,
|
||||
enhancedMangaTrackers.second.joinToString { it.name },
|
||||
(enhancedMangaTrackers.second + enhancedAnimeTrackers.second).joinToString { it.name },
|
||||
)
|
||||
enhancedMangaTrackerInfo += "\n\n$missingMangaSourcesInfo"
|
||||
enhancedTrackerInfo += "\n\n$missingSourcesInfo"
|
||||
}
|
||||
|
||||
return listOf(
|
||||
|
@ -219,7 +229,16 @@ object SettingsTrackingScreen : SearchableSettings {
|
|||
login = { (service as EnhancedMangaTracker).loginNoop() },
|
||||
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(),
|
||||
),
|
||||
)
|
||||
|
|
|
@ -3,6 +3,7 @@ package eu.kanade.tachiyomi.data.track
|
|||
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.jellyfin.Jellyfin
|
||||
import eu.kanade.tachiyomi.data.track.kavita.Kavita
|
||||
import eu.kanade.tachiyomi.data.track.kitsu.Kitsu
|
||||
import eu.kanade.tachiyomi.data.track.komga.Komga
|
||||
|
@ -19,6 +20,7 @@ class TrackerManager(context: Context) {
|
|||
const val KITSU = 3L
|
||||
const val KAVITA = 8L
|
||||
const val SIMKL = 101L
|
||||
const val JELLYFIN = 102L
|
||||
}
|
||||
|
||||
val myAnimeList = MyAnimeList(1L)
|
||||
|
@ -31,8 +33,12 @@ class TrackerManager(context: Context) {
|
|||
val kavita = Kavita(KAVITA)
|
||||
val suwayomi = Suwayomi(9L)
|
||||
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 }
|
||||
|
||||
|
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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>,
|
||||
)
|
BIN
app/src/main/res/drawable-nodpi/ic_tracker_jellyfin.webp
Normal file
BIN
app/src/main/res/drawable-nodpi/ic_tracker_jellyfin.webp
Normal file
Binary file not shown.
After Width: | Height: | Size: 31 KiB |
Loading…
Reference in a new issue